mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 21:06:25 +02:00
Compare commits
12 Commits
denon-rs23
...
simplify_i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27fe91d74 | ||
|
|
d6458bc574 | ||
|
|
434f1dca2c | ||
|
|
c6ad6da6ae | ||
|
|
be3d65538d | ||
|
|
297e9e265a | ||
|
|
119dfbddea | ||
|
|
970925141e | ||
|
|
51131beaec | ||
|
|
c509226d17 | ||
|
|
067a9a0c25 | ||
|
|
d10197d535 |
@@ -3,54 +3,27 @@ name: Home Assistant Integration knowledge
|
||||
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
|
||||
---
|
||||
|
||||
### File Locations
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## Integration Templates
|
||||
## General guidelines
|
||||
|
||||
### 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
|
||||
```
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
|
||||
|
||||
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
|
||||
The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
### Minimal Integration Checklist
|
||||
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
|
||||
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
|
||||
- [ ] `config_flow.py` with UI configuration support
|
||||
- [ ] `const.py` with `DOMAIN` constant
|
||||
- [ ] `strings.json` with at least config flow text
|
||||
- [ ] Platform files (`sensor.py`, etc.) as needed
|
||||
- [ ] `quality_scale.yaml` with rule status tracking
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
### Quality Scale Levels
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality
|
||||
- **Gold**: Advanced features
|
||||
- **Platinum**: Highest quality standards
|
||||
|
||||
### Quality Scale Progression
|
||||
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
|
||||
- **Silver → Gold**: Add device management, diagnostics, translations
|
||||
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
@@ -61,726 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
### Example `quality_scale.yaml` Structure
|
||||
```yaml
|
||||
rules:
|
||||
# Bronze (mandatory)
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
|
||||
# Silver (if targeting Silver+)
|
||||
entity-unavailable: done
|
||||
parallel-updates: done
|
||||
|
||||
# Gold (if targeting Gold+)
|
||||
devices: done
|
||||
diagnostics: done
|
||||
|
||||
# Platinum (if targeting Platinum)
|
||||
strict-typing: done
|
||||
```
|
||||
|
||||
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Core Locations
|
||||
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
|
||||
- Integration structure:
|
||||
- `homeassistant/components/{domain}/const.py` - Constants
|
||||
- `homeassistant/components/{domain}/models.py` - Data models
|
||||
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
|
||||
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
|
||||
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
|
||||
|
||||
### Common Modules
|
||||
- **coordinator.py**: Centralize data fetching logic
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
- **entity.py**: Base entity definitions to reduce duplication
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
_attr_has_entity_name = True
|
||||
```
|
||||
|
||||
### Runtime Data Storage
|
||||
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
client = MyClient(entry.data[CONF_HOST])
|
||||
entry.runtime_data = client
|
||||
```
|
||||
|
||||
### Manifest Requirements
|
||||
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
|
||||
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
|
||||
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
|
||||
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
|
||||
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
|
||||
|
||||
### Config Flow Patterns
|
||||
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
|
||||
- **Unique ID Management**:
|
||||
```python
|
||||
await self.async_set_unique_id(device_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
```
|
||||
- **Error Handling**: Define errors in `strings.json` under `config.error`
|
||||
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
|
||||
|
||||
### Integration Ownership
|
||||
- **manifest.json**: Add GitHub usernames to `codeowners`:
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@me"]
|
||||
}
|
||||
```
|
||||
|
||||
### Async Dependencies (Platinum)
|
||||
- **Requirement**: All dependencies must use asyncio
|
||||
- Ensures efficient task handling without thread context switching
|
||||
|
||||
### WebSession Injection (Platinum)
|
||||
- **Pass WebSession**: Support passing web sessions to dependencies
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Set up integration from config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
```
|
||||
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
|
||||
|
||||
### Data Update Coordinator
|
||||
- **Standard Pattern**: Use for efficient data management
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self):
|
||||
try:
|
||||
return await self.client.fetch_data()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(f"API communication error: {err}")
|
||||
```
|
||||
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
|
||||
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
### Configuration Flow
|
||||
- **UI Setup Required**: All integrations must support configuration via UI
|
||||
- **Manifest**: Set `"config_flow": true` in `manifest.json`
|
||||
- **Data Storage**:
|
||||
- Connection-critical config: Store in `ConfigEntry.data`
|
||||
- Non-critical settings: Store in `ConfigEntry.options`
|
||||
- **Validation**: Always validate user input before creating entries
|
||||
- **Config Entry Naming**:
|
||||
- ❌ Do NOT allow users to set config entry names in config flows
|
||||
- Names are automatically generated or can be customized later in UI
|
||||
- ✅ Exception: Helper integrations MAY allow custom names in config flow
|
||||
- **Connection Testing**: Test device/service connection during config flow:
|
||||
```python
|
||||
try:
|
||||
await client.get_data()
|
||||
except MyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
```
|
||||
- **Duplicate Prevention**: Prevent duplicate configurations:
|
||||
```python
|
||||
# Using unique ID
|
||||
await self.async_set_unique_id(identifier)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Using unique data
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
### Reauthentication Support
|
||||
- **Required Method**: Implement `async_step_reauth` in config flow
|
||||
- **Credential Updates**: Allow users to update credentials without re-adding
|
||||
- **Validation**: Verify account matches existing unique ID:
|
||||
```python
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
|
||||
)
|
||||
```
|
||||
|
||||
### Reconfiguration Flow
|
||||
- **Purpose**: Allow configuration updates without removing device
|
||||
- **Implementation**: Add `async_step_reconfigure` method
|
||||
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
|
||||
|
||||
### Device Discovery
|
||||
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
|
||||
```json
|
||||
{
|
||||
"zeroconf": ["_mydevice._tcp.local."]
|
||||
}
|
||||
```
|
||||
- **Discovery Handler**: Implement appropriate `async_step_*` method:
|
||||
```python
|
||||
async def async_step_zeroconf(self, discovery_info):
|
||||
"""Handle zeroconf discovery."""
|
||||
await self.async_set_unique_id(discovery_info.properties["serialno"])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
```
|
||||
- **Network Updates**: Use discovery to update dynamic IP addresses
|
||||
|
||||
### Network Discovery Implementation
|
||||
- **Zeroconf/mDNS**: Use async instances
|
||||
```python
|
||||
aiozc = await zeroconf.async_get_async_instance(hass)
|
||||
```
|
||||
- **SSDP Discovery**: Register callbacks with cleanup
|
||||
```python
|
||||
entry.async_on_unload(
|
||||
ssdp.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Bluetooth Integration
|
||||
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
|
||||
- **Connectable**: Set `"connectable": true` for connection-required devices
|
||||
- **Scanner Usage**: Always use shared scanner instance
|
||||
```python
|
||||
scanner = bluetooth.async_get_scanner()
|
||||
entry.async_on_unload(
|
||||
bluetooth.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"service_uuid": "example_uuid"},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE
|
||||
)
|
||||
)
|
||||
```
|
||||
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
|
||||
|
||||
### Setup Validation
|
||||
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
|
||||
- **Exception Handling**:
|
||||
- `ConfigEntryNotReady`: Device offline or temporary failure
|
||||
- `ConfigEntryAuthFailed`: Authentication issues
|
||||
- `ConfigEntryError`: Unresolvable setup problems
|
||||
|
||||
### Config Entry Unloading
|
||||
- **Required**: Implement `async_unload_entry` for runtime removal/reload
|
||||
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
|
||||
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
|
||||
```python
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
entry.runtime_data.listener() # Clean up resources
|
||||
return unload_ok
|
||||
```
|
||||
|
||||
### Service Actions
|
||||
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
|
||||
- **Validation**: Check config entry existence and loaded state:
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def service_action(call: ServiceCall) -> ServiceResponse:
|
||||
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
```
|
||||
- **Exception Handling**: Raise appropriate exceptions:
|
||||
```python
|
||||
# For invalid input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError("End date must be after start date")
|
||||
|
||||
# For service errors
|
||||
try:
|
||||
await client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError("Could not connect to the schedule") from err
|
||||
```
|
||||
|
||||
### Service Registration Patterns
|
||||
- **Entity Services**: Register on platform setup
|
||||
```python
|
||||
platform.async_register_entity_service(
|
||||
"my_entity_service",
|
||||
{vol.Required("parameter"): cv.string},
|
||||
"handle_service_method"
|
||||
)
|
||||
```
|
||||
- **Service Schema**: Always validate input
|
||||
```python
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
```
|
||||
- **Services File**: Create `services.yaml` with descriptions and field definitions
|
||||
|
||||
### Polling
|
||||
- Use update coordinator pattern when possible
|
||||
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
|
||||
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
|
||||
- **Minimum Intervals**:
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
- **Parallel Updates**: Specify number of concurrent updates:
|
||||
```python
|
||||
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
|
||||
# OR
|
||||
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
|
||||
```
|
||||
|
||||
## Entity Development
|
||||
|
||||
### Unique IDs
|
||||
- **Required**: Every entity must have a unique ID for registry tracking
|
||||
- Must be unique per platform (not per integration)
|
||||
- Don't include integration domain or platform in ID
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
def __init__(self, device_id: str) -> None:
|
||||
self._attr_unique_id = f"{device_id}_temperature"
|
||||
```
|
||||
|
||||
**Acceptable ID Sources**:
|
||||
- Device serial numbers
|
||||
- MAC addresses (formatted using `format_mac` from device registry)
|
||||
- Physical identifiers (printed/EEPROM)
|
||||
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
|
||||
|
||||
**Never Use**:
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
### Entity Descriptions
|
||||
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
|
||||
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
|
||||
- **Bad pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
|
||||
)
|
||||
```
|
||||
- **Good pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Naming
|
||||
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
|
||||
- **For specific fields**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
def __init__(self, device: Device, field: str) -> None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
)
|
||||
self._attr_name = field # e.g., "temperature", "humidity"
|
||||
```
|
||||
- **For device itself**: Set `_attr_name = None`
|
||||
|
||||
### Event Lifecycle Management
|
||||
- **Subscribe in `async_added_to_hass`**:
|
||||
```python
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events."""
|
||||
self.async_on_remove(
|
||||
self.client.events.subscribe("my_event", self._handle_event)
|
||||
)
|
||||
```
|
||||
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
|
||||
- Never subscribe in `__init__` or other methods
|
||||
|
||||
### State Handling
|
||||
- Unknown values: Use `None` (not "unknown" or "unavailable")
|
||||
- Availability: Implement `available()` property instead of using "unavailable" state
|
||||
|
||||
### Entity Availability
|
||||
- **Mark Unavailable**: When data cannot be fetched from device/service
|
||||
- **Coordinator Pattern**:
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.identifier in self.coordinator.data
|
||||
```
|
||||
- **Direct Update Pattern**:
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
### Extra State Attributes
|
||||
- All attribute keys must always be present
|
||||
- Unknown values: Use `None`
|
||||
- Provide descriptive attributes
|
||||
|
||||
## Device Management
|
||||
|
||||
### Device Registry
|
||||
- **Create Devices**: Group related entities under devices
|
||||
- **Device Info**: Provide comprehensive metadata:
|
||||
```python
|
||||
_attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac)},
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model="My Sensor",
|
||||
sw_version=device.version,
|
||||
)
|
||||
```
|
||||
- For services: Add `entry_type=DeviceEntryType.SERVICE`
|
||||
|
||||
### Dynamic Device Addition
|
||||
- **Auto-detect New Devices**: After initial setup
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
```
|
||||
|
||||
### Stale Device Removal
|
||||
- **Auto-remove**: When devices disappear from hub/account
|
||||
- **Device Registry Update**:
|
||||
```python
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
```
|
||||
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
|
||||
|
||||
### Entity Categories
|
||||
- **Required**: Assign appropriate category to entities
|
||||
- **Implementation**: Set `_attr_entity_category`
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
```
|
||||
- Categories include: `DIAGNOSTIC` for system/technical information
|
||||
|
||||
### Device Classes
|
||||
- **Use When Available**: Set appropriate device class for entity type
|
||||
```python
|
||||
class MyTemperatureSensor(SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
```
|
||||
- Provides context for: unit conversion, voice control, UI representation
|
||||
|
||||
### Disabled by Default
|
||||
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
|
||||
```python
|
||||
class MySignalStrengthSensor(SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
- Target: frequently changing states, technical diagnostics
|
||||
|
||||
### Entity Translations
|
||||
- **Required with has_entity_name**: Support international users
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "phase_voltage"
|
||||
```
|
||||
- Create `strings.json` with translations:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"phase_voltage": {
|
||||
"name": "Phase voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Translations (Gold)
|
||||
- **Translatable Errors**: Use translation keys for user-facing exceptions
|
||||
- **Implementation**:
|
||||
```python
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
```
|
||||
- Add to `strings.json`:
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Translations (Gold)
|
||||
- **Dynamic Icons**: Support state and range-based icon selection
|
||||
- **State-based Icons**:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"tree_pollen": {
|
||||
"default": "mdi:tree",
|
||||
"state": {
|
||||
"high": "mdi:tree-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Range-based Icons** (for numeric values):
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- **Location**: `tests/components/{domain}/`
|
||||
- **Coverage Requirement**: Above 95% test coverage for all modules
|
||||
- **Best Practices**:
|
||||
- Use pytest fixtures from `tests.common`
|
||||
- Mock all external dependencies
|
||||
- Use snapshots for complex data structures
|
||||
- Follow existing test patterns
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
- Reauthentication/reconfigure flows
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
```bash
|
||||
pytest ./tests/components/<integration_domain> \
|
||||
--cov=homeassistant.components.<integration_domain> \
|
||||
--cov-report term-missing \
|
||||
--durations-min=1 \
|
||||
--durations=0 \
|
||||
--numprocesses=auto
|
||||
```
|
||||
|
||||
### Testing Best Practices
|
||||
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
|
||||
- **Use snapshot testing** - For verifying entity states and attributes
|
||||
- **Test through integration setup** - Don't test entities in isolation
|
||||
- **Mock external APIs** - Use fixtures with realistic JSON data
|
||||
- **Verify registries** - Ensure entities are properly registered with devices
|
||||
|
||||
### Config Flow Testing Template
|
||||
```python
|
||||
async def test_user_flow_success(hass, mock_api):
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Test form submission
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
async def test_flow_connection_error(hass, mock_api_error):
|
||||
"""Test connection error handling."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
```
|
||||
|
||||
### Entity Testing Patterns
|
||||
```python
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Overridden fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR] # Or another specific platform as needed.
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
# Ensure entities are correctly assigned to device
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "device_unique_id")}
|
||||
)
|
||||
assert device_entry
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_entry.device_id == device_entry.id
|
||||
```
|
||||
|
||||
### Mock Patterns
|
||||
```python
|
||||
# Modern integration fixture setup
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="My Integration",
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
|
||||
unique_id="device_unique_id",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_api() -> Generator[MagicMock]:
|
||||
"""Return a mocked device API."""
|
||||
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
|
||||
api = api_mock.return_value
|
||||
api.get_data.return_value = MyDeviceData.from_json(
|
||||
load_fixture("device_data.json", DOMAIN)
|
||||
)
|
||||
yield api
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return PLATFORMS
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device_api: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Debugging & Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
- **Integration won't load**: Check `manifest.json` syntax and required fields
|
||||
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
|
||||
- **Config flow errors**: Check `strings.json` entries and error handling
|
||||
- **Discovery not working**: Verify manifest discovery configuration and callbacks
|
||||
- **Tests failing**: Check mock setup and async context
|
||||
|
||||
### Debug Logging Setup
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
caplog.set_level(logging.DEBUG, logger="my_integration")
|
||||
|
||||
# In integration code - use proper logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
|
||||
```
|
||||
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Check specific integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/my_integration
|
||||
|
||||
# Validate quality scale
|
||||
# Check quality_scale.yaml against current rules
|
||||
|
||||
# Run integration tests with coverage
|
||||
pytest ./tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing
|
||||
```
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
|
||||
@@ -3,17 +3,4 @@
|
||||
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
|
||||
|
||||
- **Required**: Implement diagnostic data collection
|
||||
- **Implementation**:
|
||||
```python
|
||||
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": entry.runtime_data.data,
|
||||
}
|
||||
```
|
||||
- **Security**: Never expose passwords, tokens, or sensitive coordinates
|
||||
|
||||
@@ -8,48 +8,10 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
- 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
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -355,8 +355,6 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/deluge/ @tkdrob
|
||||
/homeassistant/components/demo/ @home-assistant/core
|
||||
/tests/components/demo/ @home-assistant/core
|
||||
/homeassistant/components/denon_rs232/ @balloob
|
||||
/tests/components/denon_rs232/ @balloob
|
||||
/homeassistant/components/denonavr/ @ol-iver @starkillerOG
|
||||
/tests/components/denonavr/ @ol-iver @starkillerOG
|
||||
/homeassistant/components/derivative/ @afaucogney @karwosts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"domain": "denon",
|
||||
"name": "Denon",
|
||||
"integrations": ["denon", "denonavr", "denon_rs232", "heos"]
|
||||
"integrations": ["denon", "denonavr", "heos"]
|
||||
}
|
||||
|
||||
@@ -193,6 +193,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"todo",
|
||||
"update",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"window",
|
||||
}
|
||||
|
||||
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
31
homeassistant/components/casper_glow/diagnostics.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Diagnostics support for the Casper Glow integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import CasperGlowConfigEntry
|
||||
|
||||
SERVICE_INFO_TO_REDACT = frozenset({"address", "name", "source", "device"})
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: CasperGlowConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
service_info = bluetooth.async_last_service_info(
|
||||
hass, coordinator.device.address, connectable=True
|
||||
)
|
||||
|
||||
return {
|
||||
"service_info": async_redact_data(
|
||||
service_info.as_dict() if service_info else None,
|
||||
SERVICE_INFO_TO_REDACT,
|
||||
),
|
||||
}
|
||||
@@ -39,7 +39,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: No network discovery.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"""The Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from denon_rs232 import DenonReceiver, ReceiverState
|
||||
from denon_rs232.models import MODELS
|
||||
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
DOMAIN, # noqa: F401
|
||||
LOGGER,
|
||||
DenonRS232ConfigEntry,
|
||||
)
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Set up Denon RS232 from a config entry."""
|
||||
port = entry.data[CONF_DEVICE]
|
||||
model = MODELS[entry.data[CONF_MODEL]]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
try:
|
||||
await receiver.connect()
|
||||
await receiver.query_state()
|
||||
except (ConnectionError, OSError) as err:
|
||||
LOGGER.error("Error connecting to Denon receiver at %s: %s", port, err)
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
entry.runtime_data = receiver
|
||||
|
||||
@callback
|
||||
def _on_disconnect(state: ReceiverState | None) -> None:
|
||||
if state is None:
|
||||
LOGGER.warning("Denon receiver disconnected, reloading config entry")
|
||||
hass.config_entries.async_schedule_reload(entry.entry_id)
|
||||
|
||||
entry.async_on_unload(receiver.subscribe(_on_disconnect))
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DenonRS232ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
if unload_ok:
|
||||
await entry.runtime_data.disconnect()
|
||||
|
||||
return unload_ok
|
||||
@@ -1,142 +0,0 @@
|
||||
"""Config flow for the Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from denon_rs232 import DenonReceiver
|
||||
from denon_rs232.models import MODELS
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.usb import human_readable_device_name, scan_serial_ports
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
MODEL_OPTIONS = {key: model.name for key, model in MODELS.items()}
|
||||
|
||||
OPTION_PICK_MANUAL = "Enter Manually"
|
||||
|
||||
|
||||
async def _async_attempt_connect(port: str, model_key: str) -> str | None:
|
||||
"""Attempt to connect to the receiver at the given port.
|
||||
|
||||
Returns None on success, error on failure.
|
||||
"""
|
||||
model = MODELS[model_key]
|
||||
receiver = DenonReceiver(port, model=model)
|
||||
|
||||
try:
|
||||
await receiver.connect()
|
||||
except (
|
||||
# When the port contains invalid connection data
|
||||
ValueError,
|
||||
# If it is a remote port, and we cannot connect
|
||||
ConnectionError,
|
||||
OSError,
|
||||
):
|
||||
return "cannot_connect"
|
||||
except Exception: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception")
|
||||
return "unknown"
|
||||
else:
|
||||
await receiver.disconnect()
|
||||
return None
|
||||
|
||||
|
||||
class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Denon RS232."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_model: str
|
||||
|
||||
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:
|
||||
if user_input[CONF_DEVICE] == OPTION_PICK_MANUAL:
|
||||
self._model = user_input[CONF_MODEL]
|
||||
return await self.async_step_manual()
|
||||
|
||||
self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
|
||||
error = await _async_attempt_connect(
|
||||
user_input[CONF_DEVICE], user_input[CONF_MODEL]
|
||||
)
|
||||
if not error:
|
||||
return self.async_create_entry(
|
||||
title=MODELS[user_input[CONF_MODEL]].name,
|
||||
data={
|
||||
CONF_DEVICE: user_input[CONF_DEVICE],
|
||||
CONF_MODEL: user_input[CONF_MODEL],
|
||||
},
|
||||
)
|
||||
errors["base"] = error
|
||||
|
||||
ports = await self.hass.async_add_executor_job(get_ports)
|
||||
ports[OPTION_PICK_MANUAL] = OPTION_PICK_MANUAL
|
||||
|
||||
if user_input is None and ports:
|
||||
user_input = {CONF_DEVICE: next(iter(ports))}
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_MODEL): vol.In(MODEL_OPTIONS),
|
||||
vol.Required(CONF_DEVICE): vol.In(ports),
|
||||
}
|
||||
),
|
||||
user_input or {},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_manual(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a manual port selection."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]})
|
||||
error = await _async_attempt_connect(user_input[CONF_DEVICE], self._model)
|
||||
if not error:
|
||||
return self.async_create_entry(
|
||||
title=MODELS[self._model].name,
|
||||
data={
|
||||
CONF_DEVICE: user_input[CONF_DEVICE],
|
||||
CONF_MODEL: self._model,
|
||||
},
|
||||
)
|
||||
errors["base"] = error
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="manual",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema({vol.Required(CONF_DEVICE): str}),
|
||||
user_input or {},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
def get_ports() -> dict[str, str]:
|
||||
"""Get available serial ports keyed by their device path."""
|
||||
return {
|
||||
port.device: human_readable_device_name(
|
||||
os.path.realpath(port.device),
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
for port in scan_serial_ports()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
"""Constants for the Denon RS232 integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from denon_rs232 import DenonReceiver
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
DOMAIN = "denon_rs232"
|
||||
|
||||
type DenonRS232ConfigEntry = ConfigEntry[DenonReceiver]
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"domain": "denon_rs232",
|
||||
"name": "Denon RS232",
|
||||
"codeowners": ["@balloob"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/denon_rs232",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["denon_rs232"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["denon-rs232==3.0.0"]
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
"""Media player platform for the Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
from denon_rs232 import (
|
||||
MIN_VOLUME_DB,
|
||||
VOLUME_DB_RANGE,
|
||||
DenonReceiver,
|
||||
InputSource,
|
||||
MainPlayer,
|
||||
ReceiverState,
|
||||
ZonePlayer,
|
||||
)
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, DenonRS232ConfigEntry
|
||||
|
||||
INPUT_SOURCE_DENON_TO_HA: dict[InputSource, str] = {
|
||||
InputSource.PHONO: "phono",
|
||||
InputSource.CD: "cd",
|
||||
InputSource.TUNER: "tuner",
|
||||
InputSource.DVD: "dvd",
|
||||
InputSource.VDP: "vdp",
|
||||
InputSource.TV: "tv",
|
||||
InputSource.DBS_SAT: "dbs_sat",
|
||||
InputSource.VCR_1: "vcr_1",
|
||||
InputSource.VCR_2: "vcr_2",
|
||||
InputSource.VCR_3: "vcr_3",
|
||||
InputSource.V_AUX: "v_aux",
|
||||
InputSource.CDR_TAPE1: "cdr_tape1",
|
||||
InputSource.MD_TAPE2: "md_tape2",
|
||||
InputSource.HDP: "hdp",
|
||||
InputSource.DVR: "dvr",
|
||||
InputSource.TV_CBL: "tv_cbl",
|
||||
InputSource.SAT: "sat",
|
||||
InputSource.NET_USB: "net_usb",
|
||||
InputSource.DOCK: "dock",
|
||||
InputSource.IPOD: "ipod",
|
||||
InputSource.BD: "bd",
|
||||
InputSource.SAT_CBL: "sat_cbl",
|
||||
InputSource.MPLAY: "mplay",
|
||||
InputSource.GAME: "game",
|
||||
InputSource.AUX1: "aux1",
|
||||
InputSource.AUX2: "aux2",
|
||||
InputSource.NET: "net",
|
||||
InputSource.BT: "bt",
|
||||
InputSource.USB_IPOD: "usb_ipod",
|
||||
InputSource.EIGHT_K: "eight_k",
|
||||
InputSource.PANDORA: "pandora",
|
||||
InputSource.SIRIUSXM: "siriusxm",
|
||||
InputSource.SPOTIFY: "spotify",
|
||||
InputSource.FLICKR: "flickr",
|
||||
InputSource.IRADIO: "iradio",
|
||||
InputSource.SERVER: "server",
|
||||
InputSource.FAVORITES: "favorites",
|
||||
InputSource.LASTFM: "lastfm",
|
||||
InputSource.XM: "xm",
|
||||
InputSource.SIRIUS: "sirius",
|
||||
InputSource.HDRADIO: "hdradio",
|
||||
InputSource.DAB: "dab",
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Denon RS232 media player."""
|
||||
receiver = config_entry.runtime_data
|
||||
entities = [DenonRS232MediaPlayer(receiver, receiver.main, config_entry, "main")]
|
||||
|
||||
if receiver.zone_2.power is not None:
|
||||
entities.append(
|
||||
DenonRS232MediaPlayer(receiver, receiver.zone_2, config_entry, "zone_2")
|
||||
)
|
||||
if receiver.zone_3.power is not None:
|
||||
entities.append(
|
||||
DenonRS232MediaPlayer(receiver, receiver.zone_3, config_entry, "zone_3")
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class DenonRS232MediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of a Denon receiver controlled over RS232."""
|
||||
|
||||
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "receiver"
|
||||
_attr_should_poll = False
|
||||
|
||||
_volume_min = MIN_VOLUME_DB
|
||||
_volume_range = VOLUME_DB_RANGE
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
receiver: DenonReceiver,
|
||||
player: MainPlayer | ZonePlayer,
|
||||
config_entry: DenonRS232ConfigEntry,
|
||||
zone: Literal["main", "zone_2", "zone_3"],
|
||||
) -> None:
|
||||
"""Initialize the media player."""
|
||||
self._receiver = receiver
|
||||
self._player = player
|
||||
self._is_main = zone == "main"
|
||||
|
||||
model = receiver.model
|
||||
assert model is not None # We always set this
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, config_entry.entry_id)},
|
||||
manufacturer="Denon",
|
||||
model=model.name,
|
||||
name=config_entry.title,
|
||||
)
|
||||
self._attr_unique_id = f"{config_entry.entry_id}_{zone}"
|
||||
|
||||
self._attr_source_list = sorted(
|
||||
INPUT_SOURCE_DENON_TO_HA[source] for source in model.input_sources
|
||||
)
|
||||
self._attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_SET
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
)
|
||||
|
||||
if zone == "main":
|
||||
self._attr_name = None
|
||||
self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
else:
|
||||
self._attr_name = "Zone 2" if zone == "zone_2" else "Zone 3"
|
||||
|
||||
self._async_update_from_player()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to receiver state updates."""
|
||||
self.async_on_remove(self._receiver.subscribe(self._async_on_state_update))
|
||||
|
||||
@callback
|
||||
def _async_on_state_update(self, state: ReceiverState | None) -> None:
|
||||
"""Handle a state update from the receiver."""
|
||||
if state is None:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._async_update_from_player()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_update_from_player(self) -> None:
|
||||
"""Update entity attributes from the shared player object."""
|
||||
if self._player.power is None:
|
||||
self._attr_state = None
|
||||
else:
|
||||
self._attr_state = (
|
||||
MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF
|
||||
)
|
||||
|
||||
source = self._player.input_source
|
||||
self._attr_source = INPUT_SOURCE_DENON_TO_HA.get(source) if source else None
|
||||
|
||||
volume_min = self._player.volume_min
|
||||
volume_max = self._player.volume_max
|
||||
if volume_min is not None:
|
||||
self._volume_min = volume_min
|
||||
|
||||
if volume_max is not None and volume_max > volume_min:
|
||||
self._volume_range = volume_max - volume_min
|
||||
|
||||
volume = self._player.volume
|
||||
if volume is not None:
|
||||
self._attr_volume_level = (volume - self._volume_min) / self._volume_range
|
||||
else:
|
||||
self._attr_volume_level = None
|
||||
|
||||
if self._is_main:
|
||||
self._attr_is_volume_muted = cast(MainPlayer, self._player).mute
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the receiver on."""
|
||||
await self._player.power_on()
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn the receiver off."""
|
||||
await self._player.power_standby()
|
||||
|
||||
async def async_set_volume_level(self, volume: float) -> None:
|
||||
"""Set volume level, range 0..1."""
|
||||
db = volume * self._volume_range + self._volume_min
|
||||
await self._player.set_volume(db)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Volume up."""
|
||||
await self._player.volume_up()
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Volume down."""
|
||||
await self._player.volume_down()
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Mute or unmute."""
|
||||
player = cast(MainPlayer, self._player)
|
||||
if mute:
|
||||
await player.mute_on()
|
||||
else:
|
||||
await player.mute_off()
|
||||
|
||||
async def async_select_source(self, source: str) -> None:
|
||||
"""Select input source."""
|
||||
input_source = next(
|
||||
(
|
||||
input_source
|
||||
for input_source, ha_source in INPUT_SOURCE_DENON_TO_HA.items()
|
||||
if ha_source == source
|
||||
),
|
||||
None,
|
||||
)
|
||||
if input_source is None:
|
||||
raise HomeAssistantError("Invalid source")
|
||||
|
||||
await self._player.select_input_source(input_source)
|
||||
@@ -1,60 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup: done
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: todo
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: todo
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow: todo
|
||||
test-coverage: todo
|
||||
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
@@ -1,86 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"manual": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"device": "[%key:component::denon_rs232::config::step::user::data_description::device%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::port%]",
|
||||
"model": "Receiver model"
|
||||
},
|
||||
"data_description": {
|
||||
"device": "Serial port path to connect to",
|
||||
"model": "Determines available features"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"media_player": {
|
||||
"receiver": {
|
||||
"state_attributes": {
|
||||
"source": {
|
||||
"name": "Source",
|
||||
"state": {
|
||||
"aux1": "Aux 1",
|
||||
"aux2": "Aux 2",
|
||||
"bd": "BD Player",
|
||||
"bt": "Bluetooth",
|
||||
"cd": "CD",
|
||||
"cdr_tape1": "CDR/Tape 1",
|
||||
"dab": "DAB",
|
||||
"dbs_sat": "DBS/Sat",
|
||||
"dock": "Dock",
|
||||
"dvd": "DVD",
|
||||
"dvr": "DVR",
|
||||
"eight_k": "8K",
|
||||
"favorites": "Favorites",
|
||||
"flickr": "Flickr",
|
||||
"game": "Game",
|
||||
"hdp": "HDP",
|
||||
"hdradio": "HD Radio",
|
||||
"ipod": "iPod",
|
||||
"iradio": "Internet Radio",
|
||||
"lastfm": "Last.fm",
|
||||
"md_tape2": "MD/Tape 2",
|
||||
"mplay": "Media Player",
|
||||
"net": "HEOS Music",
|
||||
"net_usb": "Network/USB",
|
||||
"pandora": "Pandora",
|
||||
"phono": "Phono",
|
||||
"sat": "Sat",
|
||||
"sat_cbl": "Satellite/Cable",
|
||||
"server": "Server",
|
||||
"sirius": "Sirius",
|
||||
"siriusxm": "SiriusXM",
|
||||
"spotify": "Spotify",
|
||||
"tuner": "Tuner",
|
||||
"tv": "TV Audio",
|
||||
"tv_cbl": "TV/Cable",
|
||||
"usb_ipod": "USB/iPod",
|
||||
"v_aux": "V. Aux",
|
||||
"vcr_1": "VCR 1",
|
||||
"vcr_2": "VCR 2",
|
||||
"vcr_3": "VCR 3",
|
||||
"vdp": "VDP",
|
||||
"xm": "XM"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,23 +34,17 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery-update-info: done
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations:
|
||||
status: exempt
|
||||
comment: no known limitations, yet
|
||||
docs-supported-devices:
|
||||
status: todo
|
||||
comment: add the known supported devices
|
||||
docs-supported-functions:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases:
|
||||
status: todo
|
||||
comment: need to be overhauled
|
||||
docs-use-cases: done
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -48,12 +48,6 @@
|
||||
"message": "Sending notification to {target} failed due to a request error"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue": {
|
||||
"description": "Configuring HTML5 push notification using YAML has been deprecated. An automatic import of your existing configuration was attempted, but it failed.\n\nPlease remove the HTML5 push notification YAML configuration from your configuration.yaml file and reconfigure HTML5 push notification again manually.",
|
||||
"title": "HTML5 YAML configuration import failed"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"dismiss": {
|
||||
"description": "Dismisses an HTML5 notification.",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2026.3.2.183756"
|
||||
"knx-frontend==2026.3.28.223133"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from motionblinds import AsyncMotionMulticast
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
@@ -14,32 +12,28 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from .const import (
|
||||
CONF_BLIND_TYPE_LIST,
|
||||
CONF_INTERFACE,
|
||||
CONF_WAIT_FOR_PUSH,
|
||||
DEFAULT_INTERFACE,
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
DOMAIN,
|
||||
KEY_API_LOCK,
|
||||
KEY_COORDINATOR,
|
||||
KEY_GATEWAY,
|
||||
KEY_MULTICAST_LISTENER,
|
||||
KEY_SETUP_LOCK,
|
||||
KEY_UNSUB_STOP,
|
||||
PLATFORMS,
|
||||
)
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
|
||||
from .gateway import ConnectMotionGateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MotionBlindsConfigEntry
|
||||
) -> bool:
|
||||
"""Set up the motion_blinds components from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
setup_lock = hass.data[DOMAIN].setdefault(KEY_SETUP_LOCK, asyncio.Lock())
|
||||
host = entry.data[CONF_HOST]
|
||||
key = entry.data[CONF_API_KEY]
|
||||
multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE)
|
||||
wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH)
|
||||
blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST)
|
||||
|
||||
# Create multicast Listener
|
||||
@@ -88,15 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
):
|
||||
raise ConfigEntryNotReady
|
||||
motion_gateway = connect_gateway_class.gateway_device
|
||||
api_lock = asyncio.Lock()
|
||||
coordinator_info = {
|
||||
KEY_GATEWAY: motion_gateway,
|
||||
KEY_API_LOCK: api_lock,
|
||||
CONF_WAIT_FOR_PUSH: wait_for_push,
|
||||
}
|
||||
|
||||
coordinator = DataUpdateCoordinatorMotionBlinds(
|
||||
hass, entry, _LOGGER, coordinator_info
|
||||
hass, entry, _LOGGER, motion_gateway
|
||||
)
|
||||
|
||||
# store blind type list for next time
|
||||
@@ -110,20 +98,16 @@ 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[DOMAIN][entry.entry_id] = {
|
||||
KEY_GATEWAY: motion_gateway,
|
||||
KEY_COORDINATOR: coordinator,
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
assert entry.unique_id is not None
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: MotionBlindsConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
@@ -132,7 +116,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
if unload_ok:
|
||||
multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER]
|
||||
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
# No motion gateways left, stop Motion multicast
|
||||
|
||||
@@ -5,25 +5,23 @@ from __future__ import annotations
|
||||
from motionblinds.motion_blinds import LimitStatus, MotionBlind
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds
|
||||
from .coordinator import DataUpdateCoordinatorMotionBlinds, MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Perform the setup for Motionblinds."""
|
||||
entities: list[ButtonEntity] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
if blind.limit_status in (
|
||||
|
||||
@@ -9,7 +9,6 @@ from motionblinds import MotionDiscovery, MotionGateway
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlowWithReload,
|
||||
@@ -27,6 +26,7 @@ from .const import (
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .gateway import ConnectMotionGateway
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -79,7 +79,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
) -> OptionsFlowHandler:
|
||||
"""Get the options flow."""
|
||||
return OptionsFlowHandler()
|
||||
|
||||
@@ -16,7 +16,6 @@ DEFAULT_INTERFACE = "any"
|
||||
|
||||
KEY_GATEWAY = "gateway"
|
||||
KEY_API_LOCK = "api_lock"
|
||||
KEY_COORDINATOR = "coordinator"
|
||||
KEY_MULTICAST_LISTENER = "multicast_listener"
|
||||
KEY_SETUP_LOCK = "setup_lock"
|
||||
KEY_UNSUB_STOP = "unsub_stop"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""DataUpdateCoordinator for Motionblinds integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from motionblinds import DEVICE_TYPES_WIFI, ParseException
|
||||
from motionblinds import DEVICE_TYPES_WIFI, MotionGateway, ParseException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -14,7 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from .const import (
|
||||
ATTR_AVAILABLE,
|
||||
CONF_WAIT_FOR_PUSH,
|
||||
KEY_API_LOCK,
|
||||
DEFAULT_WAIT_FOR_PUSH,
|
||||
KEY_GATEWAY,
|
||||
UPDATE_INTERVAL,
|
||||
UPDATE_INTERVAL_FAST,
|
||||
@@ -23,17 +24,20 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type MotionBlindsConfigEntry = ConfigEntry[DataUpdateCoordinatorMotionBlinds]
|
||||
|
||||
|
||||
class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
"""Class to manage fetching data from single endpoint."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MotionBlindsConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
logger: logging.Logger,
|
||||
coordinator_info: dict[str, Any],
|
||||
gateway: MotionGateway,
|
||||
) -> None:
|
||||
"""Initialize global data updater."""
|
||||
super().__init__(
|
||||
@@ -44,14 +48,16 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
|
||||
self.api_lock = coordinator_info[KEY_API_LOCK]
|
||||
self._gateway = coordinator_info[KEY_GATEWAY]
|
||||
self._wait_for_push = coordinator_info[CONF_WAIT_FOR_PUSH]
|
||||
self.api_lock = asyncio.Lock()
|
||||
self.gateway = gateway
|
||||
self._wait_for_push = config_entry.options.get(
|
||||
CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH
|
||||
)
|
||||
|
||||
def update_gateway(self):
|
||||
"""Fetch data from gateway."""
|
||||
try:
|
||||
self._gateway.Update()
|
||||
self.gateway.Update()
|
||||
except TimeoutError, ParseException:
|
||||
# let the error be logged and handled by the motionblinds library
|
||||
return {ATTR_AVAILABLE: False}
|
||||
@@ -82,7 +88,7 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
|
||||
self.update_gateway
|
||||
)
|
||||
|
||||
for blind in self._gateway.device_list.values():
|
||||
for blind in self.gateway.device_list.values():
|
||||
await asyncio.sleep(1.5)
|
||||
async with self.api_lock:
|
||||
data[blind.mac] = await self.hass.async_add_executor_job(
|
||||
|
||||
@@ -15,7 +15,6 @@ from homeassistant.components.cover import (
|
||||
CoverEntity,
|
||||
CoverEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -25,12 +24,11 @@ from .const import (
|
||||
ATTR_ABSOLUTE_POSITION,
|
||||
ATTR_AVAILABLE,
|
||||
ATTR_WIDTH,
|
||||
DOMAIN,
|
||||
KEY_COORDINATOR,
|
||||
KEY_GATEWAY,
|
||||
SERVICE_SET_ABSOLUTE_POSITION,
|
||||
UPDATE_DELAY_STOP,
|
||||
)
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -84,13 +82,13 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = {
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Motion Blind from a config entry."""
|
||||
entities: list[MotionBaseDevice] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
if blind.type in POSITION_DEVICE_MAP:
|
||||
|
||||
@@ -10,7 +10,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
@@ -19,7 +18,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
|
||||
from .coordinator import MotionBlindsConfigEntry
|
||||
from .entity import MotionCoordinatorEntity
|
||||
|
||||
ATTR_BATTERY_VOLTAGE = "battery_voltage"
|
||||
@@ -27,13 +26,13 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage"
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MotionBlindsConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Perform the setup for Motionblinds."""
|
||||
entities: list[SensorEntity] = []
|
||||
motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY]
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR]
|
||||
coordinator = config_entry.runtime_data
|
||||
motion_gateway = coordinator.gateway
|
||||
|
||||
for blind in motion_gateway.device_list.values():
|
||||
entities.append(MotionSignalStrengthSensor(coordinator, blind))
|
||||
|
||||
@@ -73,8 +73,8 @@ class SureBattery(SurePetcareEntity, SensorEntity):
|
||||
try:
|
||||
per_battery_voltage = state["battery"] / 4
|
||||
voltage_diff = per_battery_voltage - SURE_BATT_VOLTAGE_LOW
|
||||
self._attr_native_value = min(
|
||||
int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100
|
||||
self._attr_native_value = max(
|
||||
0, min(int(voltage_diff / SURE_BATT_VOLTAGE_DIFF * 100), 100)
|
||||
)
|
||||
except KeyError, TypeError:
|
||||
self._attr_native_value = None
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["tuya_sharing"],
|
||||
"requirements": [
|
||||
"tuya-device-handlers==0.0.15",
|
||||
"tuya-device-handlers==0.0.16",
|
||||
"tuya-device-sharing-sdk==0.2.8"
|
||||
]
|
||||
}
|
||||
|
||||
41
homeassistant/components/unifi_access/diagnostics.py
Normal file
41
homeassistant/components/unifi_access/diagnostics.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Diagnostics support for UniFi Access."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import UnifiAccessConfigEntry
|
||||
|
||||
TO_REDACT = {CONF_API_TOKEN}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: UnifiAccessConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
data = entry.runtime_data.data
|
||||
return {
|
||||
"entry_data": async_redact_data(dict(entry.data), TO_REDACT),
|
||||
"coordinator_data": {
|
||||
"doors": {
|
||||
door_id: door.model_dump(mode="json")
|
||||
for door_id, door in data.doors.items()
|
||||
},
|
||||
"emergency": data.emergency.model_dump(mode="json"),
|
||||
"door_lock_rules": {
|
||||
door_id: rule.model_dump(mode="json")
|
||||
for door_id, rule in data.door_lock_rules.items()
|
||||
},
|
||||
"unconfirmed_lock_rule_doors": sorted(data.unconfirmed_lock_rule_doors),
|
||||
"supports_lock_rules": data.supports_lock_rules,
|
||||
"lock_rule_support_complete": data.lock_rule_support_complete,
|
||||
"door_thumbnails": {
|
||||
door_id: thumb.model_dump(mode="json")
|
||||
for door_id, thumb in data.door_thumbnails.items()
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -41,7 +41,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
docs-data-update: todo
|
||||
|
||||
@@ -40,5 +40,13 @@
|
||||
"toggle": {
|
||||
"service": "mdi:valve-open"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"trigger": "mdi:valve-closed"
|
||||
},
|
||||
"opened": {
|
||||
"trigger": "mdi:valve-open"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"trigger_behavior_description": "The behavior of the targeted valves to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_closed": {
|
||||
"description": "Tests if one or more valves are closed.",
|
||||
@@ -50,6 +54,13 @@
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -80,5 +91,27 @@
|
||||
"name": "Toggle valve"
|
||||
}
|
||||
},
|
||||
"title": "Valve"
|
||||
"title": "Valve",
|
||||
"triggers": {
|
||||
"closed": {
|
||||
"description": "Triggers after one or more valves close.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::valve::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::valve::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Valve closed"
|
||||
},
|
||||
"opened": {
|
||||
"description": "Triggers after one or more valves open.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::valve::common::trigger_behavior_description%]",
|
||||
"name": "[%key:component::valve::common::trigger_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Valve opened"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
homeassistant/components/valve/trigger.py
Normal file
24
homeassistant/components/valve/trigger.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Provides triggers for valves."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.automation import DomainSpec
|
||||
from homeassistant.helpers.trigger import Trigger, make_entity_transition_trigger
|
||||
|
||||
from . import ATTR_IS_CLOSED, DOMAIN
|
||||
|
||||
VALVE_DOMAIN_SPECS = {DOMAIN: DomainSpec(value_source=ATTR_IS_CLOSED)}
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
"closed": make_entity_transition_trigger(
|
||||
VALVE_DOMAIN_SPECS, from_states={False}, to_states={True}
|
||||
),
|
||||
"opened": make_entity_transition_trigger(
|
||||
VALVE_DOMAIN_SPECS, from_states={True}, to_states={False}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for valves."""
|
||||
return TRIGGERS
|
||||
18
homeassistant/components/valve/triggers.yaml
Normal file
18
homeassistant/components/valve/triggers.yaml
Normal file
@@ -0,0 +1,18 @@
|
||||
.trigger_common: &trigger_common
|
||||
target:
|
||||
entity:
|
||||
domain: valve
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
|
||||
closed: *trigger_common
|
||||
opened: *trigger_common
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["weatherflow4py"],
|
||||
"requirements": ["weatherflow4py==1.4.1"]
|
||||
"requirements": ["weatherflow4py==1.5.2"]
|
||||
}
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -142,7 +142,6 @@ FLOWS = {
|
||||
"deconz",
|
||||
"decora_wifi",
|
||||
"deluge",
|
||||
"denon_rs232",
|
||||
"denonavr",
|
||||
"devialet",
|
||||
"devolo_home_control",
|
||||
|
||||
@@ -1317,12 +1317,6 @@
|
||||
"iot_class": "local_push",
|
||||
"name": "Denon AVR Network Receivers"
|
||||
},
|
||||
"denon_rs232": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push",
|
||||
"name": "Denon RS232"
|
||||
},
|
||||
"heos": {
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
|
||||
@@ -482,8 +482,8 @@ class EntityTargetStateTriggerBase(EntityTriggerBase):
|
||||
class EntityTransitionTriggerBase(EntityTriggerBase):
|
||||
"""Trigger for entity state changes between specific states."""
|
||||
|
||||
_from_states: set[str]
|
||||
_to_states: set[str]
|
||||
_from_states: set[str | bool]
|
||||
_to_states: set[str | bool]
|
||||
|
||||
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
|
||||
"""Check if the origin state matches the expected ones."""
|
||||
@@ -838,8 +838,8 @@ def make_entity_target_state_trigger(
|
||||
def make_entity_transition_trigger(
|
||||
domain_specs: Mapping[str, DomainSpec] | str,
|
||||
*,
|
||||
from_states: set[str],
|
||||
to_states: set[str],
|
||||
from_states: set[str | bool],
|
||||
to_states: set[str | bool],
|
||||
) -> type[EntityTransitionTriggerBase]:
|
||||
"""Create a trigger for entity state changes between specific states.
|
||||
|
||||
|
||||
9
requirements_all.txt
generated
9
requirements_all.txt
generated
@@ -803,9 +803,6 @@ deluge-client==1.10.2
|
||||
# homeassistant.components.lametric
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denon_rs232
|
||||
denon-rs232==3.0.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.2
|
||||
|
||||
@@ -1386,7 +1383,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.3.2.183756
|
||||
knx-frontend==2026.3.28.223133
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -3160,7 +3157,7 @@ ttls==1.8.3
|
||||
ttn_client==1.3.0
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.15
|
||||
tuya-device-handlers==0.0.16
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
@@ -3283,7 +3280,7 @@ waterfurnace==1.6.2
|
||||
watergate-local-api==2025.1.0
|
||||
|
||||
# homeassistant.components.weatherflow_cloud
|
||||
weatherflow4py==1.4.1
|
||||
weatherflow4py==1.5.2
|
||||
|
||||
# homeassistant.components.cisco_webex_teams
|
||||
webexpythonsdk==2.0.1
|
||||
|
||||
9
requirements_test_all.txt
generated
9
requirements_test_all.txt
generated
@@ -715,9 +715,6 @@ deluge-client==1.10.2
|
||||
# homeassistant.components.lametric
|
||||
demetriek==1.3.0
|
||||
|
||||
# homeassistant.components.denon_rs232
|
||||
denon-rs232==3.0.0
|
||||
|
||||
# homeassistant.components.denonavr
|
||||
denonavr==1.3.2
|
||||
|
||||
@@ -1226,7 +1223,7 @@ kegtron-ble==1.0.2
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.3.2.183756
|
||||
knx-frontend==2026.3.28.223133
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -2672,7 +2669,7 @@ ttls==1.8.3
|
||||
ttn_client==1.3.0
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-handlers==0.0.15
|
||||
tuya-device-handlers==0.0.16
|
||||
|
||||
# homeassistant.components.tuya
|
||||
tuya-device-sharing-sdk==0.2.8
|
||||
@@ -2780,7 +2777,7 @@ waterfurnace==1.6.2
|
||||
watergate-local-api==2025.1.0
|
||||
|
||||
# homeassistant.components.weatherflow_cloud
|
||||
weatherflow4py==1.4.1
|
||||
weatherflow4py==1.5.2
|
||||
|
||||
# homeassistant.components.nasweb
|
||||
webio-api==0.1.12
|
||||
|
||||
36
tests/components/casper_glow/snapshots/test_diagnostics.ambr
Normal file
36
tests/components/casper_glow/snapshots/test_diagnostics.ambr
Normal file
@@ -0,0 +1,36 @@
|
||||
# serializer version: 1
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'service_info': dict({
|
||||
'address': '**REDACTED**',
|
||||
'advertisement': list([
|
||||
'Jar',
|
||||
dict({
|
||||
}),
|
||||
dict({
|
||||
}),
|
||||
list([
|
||||
]),
|
||||
-127,
|
||||
-60,
|
||||
list([
|
||||
list([
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
'connectable': True,
|
||||
'device': '**REDACTED**',
|
||||
'manufacturer_data': dict({
|
||||
}),
|
||||
'name': '**REDACTED**',
|
||||
'raw': None,
|
||||
'rssi': -60,
|
||||
'service_data': dict({
|
||||
}),
|
||||
'service_uuids': list([
|
||||
]),
|
||||
'source': '**REDACTED**',
|
||||
'tx_power': -127,
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
33
tests/components/casper_glow/test_diagnostics.py
Normal file
33
tests/components/casper_glow/test_diagnostics.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Test the Casper Glow diagnostics."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from syrupy.filters import props
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
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_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_casper_glow: MagicMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics for config entry."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
result = await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, mock_config_entry
|
||||
)
|
||||
assert result == snapshot(
|
||||
exclude=props("created_at", "modified_at", "entry_id", "time")
|
||||
)
|
||||
@@ -1,4 +0,0 @@
|
||||
"""Tests for the Denon RS232 integration."""
|
||||
|
||||
MOCK_DEVICE = "/dev/ttyUSB0"
|
||||
MOCK_MODEL = "avr_3805"
|
||||
@@ -1,194 +0,0 @@
|
||||
"""Test fixtures for the Denon RS232 integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from denon_rs232 import (
|
||||
DenonReceiver,
|
||||
DigitalInputMode,
|
||||
InputSource,
|
||||
ReceiverState,
|
||||
TunerBand,
|
||||
TunerMode,
|
||||
ZoneState,
|
||||
)
|
||||
from denon_rs232.models import MODELS
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.denon_rs232.const import DOMAIN
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import MOCK_DEVICE, MOCK_MODEL
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ZoneName = Literal["main", "zone_2", "zone_3"]
|
||||
|
||||
|
||||
class MockMainStateView:
|
||||
"""Main-zone view over the receiver state."""
|
||||
|
||||
def __init__(self, state: MockState) -> None:
|
||||
"""Initialize the main-zone state view."""
|
||||
self._state = state
|
||||
|
||||
@property
|
||||
def power(self) -> bool | None:
|
||||
"""Return the main-zone power state."""
|
||||
return self._state.main_zone_power
|
||||
|
||||
@power.setter
|
||||
def power(self, value: bool | None) -> None:
|
||||
self._state.main_zone_power = value
|
||||
|
||||
@property
|
||||
def input_source(self) -> InputSource | None:
|
||||
"""Return the main-zone input source."""
|
||||
return self._state.input_source
|
||||
|
||||
@input_source.setter
|
||||
def input_source(self, value: InputSource | None) -> None:
|
||||
self._state.input_source = value
|
||||
|
||||
@property
|
||||
def volume(self) -> float | None:
|
||||
"""Return the main-zone volume."""
|
||||
return self._state.volume
|
||||
|
||||
@volume.setter
|
||||
def volume(self, value: float | None) -> None:
|
||||
self._state.volume = value
|
||||
|
||||
|
||||
class MockState(ReceiverState):
|
||||
"""Receiver state with helpers for zone-oriented tests."""
|
||||
|
||||
def get_zone(self, zone: ZoneName) -> MockMainStateView | ZoneState:
|
||||
"""Return the requested zone state view."""
|
||||
if zone == "main":
|
||||
return MockMainStateView(self)
|
||||
return getattr(self, zone)
|
||||
|
||||
|
||||
class MockReceiver(DenonReceiver):
|
||||
"""Receiver test double built on the real receiver/player objects."""
|
||||
|
||||
def __init__(self, state: MockState) -> None:
|
||||
"""Initialize the mock receiver."""
|
||||
super().__init__(MOCK_DEVICE, model=MODELS[MOCK_MODEL])
|
||||
self._connected = True
|
||||
self._load_state(state)
|
||||
self._send_command = AsyncMock()
|
||||
self._query = AsyncMock()
|
||||
self.connect = AsyncMock(side_effect=self._mock_connect)
|
||||
self.query_state = AsyncMock()
|
||||
self.disconnect = AsyncMock(side_effect=self._mock_disconnect)
|
||||
|
||||
def get_zone(self, zone: ZoneName):
|
||||
"""Return the matching live player object."""
|
||||
if zone == "main":
|
||||
return self.main
|
||||
if zone == "zone_2":
|
||||
return self.zone_2
|
||||
return self.zone_3
|
||||
|
||||
def mock_state(self, state: MockState | None) -> None:
|
||||
"""Push a state update through the receiver."""
|
||||
self._connected = state is not None
|
||||
if state is not None:
|
||||
self._load_state(state)
|
||||
self._notify_subscribers()
|
||||
|
||||
async def _mock_connect(self) -> None:
|
||||
"""Pretend to open the serial connection."""
|
||||
self._connected = True
|
||||
|
||||
async def _mock_disconnect(self) -> None:
|
||||
"""Pretend to close the serial connection."""
|
||||
self._connected = False
|
||||
|
||||
def _load_state(self, state: MockState) -> None:
|
||||
"""Swap in a new state object and rebind the live players to it."""
|
||||
self._state = state
|
||||
self.main._state = state
|
||||
self.main._main_state = state
|
||||
self.zone_2._state = state.zone_2
|
||||
self.zone_3._state = state.zone_3
|
||||
|
||||
|
||||
def _default_state() -> MockState:
|
||||
"""Return a ReceiverState with typical defaults."""
|
||||
return MockState(
|
||||
power=True,
|
||||
main_zone_power=True,
|
||||
volume=-30.0,
|
||||
volume_min=-80,
|
||||
volume_max=10,
|
||||
mute=False,
|
||||
input_source=InputSource.CD,
|
||||
surround_mode="STEREO",
|
||||
digital_input=DigitalInputMode.AUTO,
|
||||
tuner_band=TunerBand.FM,
|
||||
tuner_mode=TunerMode.AUTO,
|
||||
zone_2=ZoneState(
|
||||
power=True,
|
||||
input_source=InputSource.TUNER,
|
||||
volume=-20.0,
|
||||
),
|
||||
zone_3=ZoneState(
|
||||
power=False,
|
||||
input_source=InputSource.CD,
|
||||
volume=-35.0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def initial_receiver_state(request: pytest.FixtureRequest) -> MockState:
|
||||
"""Return the initial receiver state for a test."""
|
||||
state = _default_state()
|
||||
|
||||
if getattr(request, "param", None) == "main_only":
|
||||
state.zone_2 = ZoneState()
|
||||
state.zone_3 = ZoneState()
|
||||
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_receiver(initial_receiver_state: MockState) -> MockReceiver:
|
||||
"""Create a mock DenonReceiver."""
|
||||
return MockReceiver(initial_receiver_state)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Create a mock config entry."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
title=MODELS[MOCK_MODEL].name,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_components(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MockReceiver,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the Denon component."""
|
||||
hass.config.components.add("usb")
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
@@ -1,244 +0,0 @@
|
||||
"""Tests for the Denon RS232 config flow."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.denon_rs232.config_flow import OPTION_PICK_MANUAL
|
||||
from homeassistant.components.denon_rs232.const import DOMAIN
|
||||
from homeassistant.components.usb import USBDevice
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_DEVICE, CONF_MODEL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import MOCK_DEVICE, MOCK_MODEL
|
||||
|
||||
from tests.common import MockConfigEntry, get_schema_suggested_value
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_list_serial_ports() -> Generator[list[USBDevice]]:
|
||||
"""Mock discovered serial ports."""
|
||||
ports = [
|
||||
USBDevice(
|
||||
device=MOCK_DEVICE,
|
||||
vid="123",
|
||||
pid="456",
|
||||
serial_number="mock-serial",
|
||||
manufacturer="mock-manuf",
|
||||
description=None,
|
||||
)
|
||||
]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.scan_serial_ports",
|
||||
return_value=ports,
|
||||
):
|
||||
yield ports
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_async_setup_entry(mock_receiver: MagicMock) -> Generator[AsyncMock]:
|
||||
"""Prevent config-entry creation tests from setting up the integration."""
|
||||
|
||||
async def _mock_setup_entry(hass: HomeAssistant, entry) -> bool:
|
||||
entry.runtime_data = mock_receiver
|
||||
return True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.async_setup_entry",
|
||||
side_effect=_mock_setup_entry,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
async def test_user_form_creates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test successful config flow creates an entry."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "AVR-3805 / AVC-3890"
|
||||
assert result["data"] == {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL}
|
||||
mock_async_setup_entry.assert_awaited_once()
|
||||
mock_receiver.connect.assert_awaited_once()
|
||||
mock_receiver.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(ConnectionError("No response"), "cannot_connect"),
|
||||
(OSError("No such device"), "cannot_connect"),
|
||||
(RuntimeError("boom"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_user_form_error(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
mock_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test the user step reports connection and unexpected errors."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
mock_receiver.connect.side_effect = exception
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
|
||||
async def test_user_duplicate_port_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if the same port is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
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_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
async def test_manual_form_creates_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MagicMock,
|
||||
mock_async_setup_entry: AsyncMock,
|
||||
) -> None:
|
||||
"""Test creating entry with manual user input."""
|
||||
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_DEVICE: OPTION_PICK_MANUAL, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "manual"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "AVR-3805 / AVC-3890"
|
||||
assert result["data"] == {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL}
|
||||
mock_async_setup_entry.assert_awaited_once()
|
||||
mock_receiver.connect.assert_awaited_once()
|
||||
mock_receiver.disconnect.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(ValueError("Invalid port"), "cannot_connect"),
|
||||
(ConnectionError("No response"), "cannot_connect"),
|
||||
(OSError("No such device"), "cannot_connect"),
|
||||
(RuntimeError("boom"), "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_manual_form_error_handling(
|
||||
hass: HomeAssistant,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
mock_receiver: MagicMock,
|
||||
) -> None:
|
||||
"""Test the manual step reports connection and unexpected errors."""
|
||||
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_DEVICE: OPTION_PICK_MANUAL, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "manual"
|
||||
mock_receiver.connect.side_effect = exception
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.denon_rs232.config_flow.DenonReceiver",
|
||||
return_value=mock_receiver,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "manual"
|
||||
assert result["errors"] == {"base": error}
|
||||
assert (
|
||||
get_schema_suggested_value(result["data_schema"].schema, CONF_DEVICE)
|
||||
== MOCK_DEVICE
|
||||
)
|
||||
|
||||
|
||||
async def test_manual_duplicate_port_aborts(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if the same port is already configured."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
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_DEVICE: OPTION_PICK_MANUAL, CONF_MODEL: MOCK_MODEL},
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_DEVICE: MOCK_DEVICE},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -1,318 +0,0 @@
|
||||
"""Tests for the Denon RS232 media player platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from unittest.mock import call
|
||||
|
||||
from denon_rs232 import InputSource
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.denon_rs232.media_player import INPUT_SOURCE_DENON_TO_HA
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_INPUT_SOURCE_LIST,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
)
|
||||
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,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import MockReceiver, MockState, _default_state
|
||||
|
||||
ZoneName = Literal["main", "zone_2", "zone_3"]
|
||||
|
||||
MAIN_ENTITY_ID = "media_player.avr_3805_avc_3890"
|
||||
ZONE_2_ENTITY_ID = "media_player.avr_3805_avc_3890_zone_2"
|
||||
ZONE_3_ENTITY_ID = "media_player.avr_3805_avc_3890_zone_3"
|
||||
|
||||
STRINGS_PATH = Path("homeassistant/components/denon_rs232/strings.json")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def auto_init_components(init_components) -> None:
|
||||
"""Set up the component."""
|
||||
|
||||
|
||||
async def test_entities_created(
|
||||
hass: HomeAssistant, mock_receiver: MockReceiver
|
||||
) -> None:
|
||||
"""Test media player entities are created through config entry setup."""
|
||||
assert hass.states.get(MAIN_ENTITY_ID) is not None
|
||||
assert hass.states.get(ZONE_2_ENTITY_ID) is not None
|
||||
assert hass.states.get(ZONE_3_ENTITY_ID) is not None
|
||||
mock_receiver.query_state.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_receiver_state", ["main_only"], indirect=True)
|
||||
async def test_only_active_zones_are_created(
|
||||
hass: HomeAssistant, initial_receiver_state: MockState
|
||||
) -> None:
|
||||
"""Test setup only creates entities for zones with queried power state."""
|
||||
assert hass.states.get(MAIN_ENTITY_ID) is not None
|
||||
assert hass.states.get(ZONE_2_ENTITY_ID) is None
|
||||
assert hass.states.get(ZONE_3_ENTITY_ID) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("zone", "entity_id", "initial_entity_state"),
|
||||
[
|
||||
("main", MAIN_ENTITY_ID, STATE_ON),
|
||||
("zone_2", ZONE_2_ENTITY_ID, STATE_ON),
|
||||
("zone_3", ZONE_3_ENTITY_ID, STATE_OFF),
|
||||
],
|
||||
)
|
||||
async def test_zone_state_updates(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MockReceiver,
|
||||
zone: ZoneName,
|
||||
entity_id: str,
|
||||
initial_entity_state: str,
|
||||
) -> None:
|
||||
"""Test each zone updates from receiver pushes and disconnects."""
|
||||
assert hass.states.get(entity_id).state == initial_entity_state
|
||||
|
||||
state = _default_state()
|
||||
state.get_zone(zone).power = initial_entity_state != STATE_ON
|
||||
mock_receiver.mock_state(state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state != initial_entity_state
|
||||
|
||||
mock_receiver.mock_state(None)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("zone", "entity_id", "power_on_command", "power_off_command"),
|
||||
[
|
||||
("main", MAIN_ENTITY_ID, ("ZM", "ON"), ("ZM", "OFF")),
|
||||
("zone_2", ZONE_2_ENTITY_ID, ("Z2", "ON"), ("Z2", "OFF")),
|
||||
("zone_3", ZONE_3_ENTITY_ID, ("Z1", "ON"), ("Z1", "OFF")),
|
||||
],
|
||||
)
|
||||
async def test_power_controls(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MockReceiver,
|
||||
zone: ZoneName,
|
||||
entity_id: str,
|
||||
power_on_command: tuple[str, str],
|
||||
power_off_command: tuple[str, str],
|
||||
) -> None:
|
||||
"""Test power services send the right commands for each zone."""
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*power_on_command)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*power_off_command)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"zone",
|
||||
"entity_id",
|
||||
"initial_volume_level",
|
||||
"set_command",
|
||||
"volume_up_command",
|
||||
"volume_down_command",
|
||||
),
|
||||
[
|
||||
(
|
||||
"main",
|
||||
MAIN_ENTITY_ID,
|
||||
50.0 / 90.0,
|
||||
("MV", "45"),
|
||||
("MV", "UP"),
|
||||
("MV", "DOWN"),
|
||||
),
|
||||
(
|
||||
"zone_2",
|
||||
ZONE_2_ENTITY_ID,
|
||||
60.0 / 90.0,
|
||||
("Z2", "45"),
|
||||
("Z2", "UP"),
|
||||
("Z2", "DOWN"),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_volume_controls(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MockReceiver,
|
||||
zone: ZoneName,
|
||||
entity_id: str,
|
||||
initial_volume_level: float,
|
||||
set_command: tuple[str, str],
|
||||
volume_up_command: tuple[str, str],
|
||||
volume_down_command: tuple[str, str],
|
||||
) -> None:
|
||||
"""Test volume state and controls for each zone."""
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
assert abs(state.attributes[ATTR_MEDIA_VOLUME_LEVEL] - initial_volume_level) < 0.001
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_SET,
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_MEDIA_VOLUME_LEVEL: 0.5},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*set_command)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_UP,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*volume_up_command)
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_DOWN,
|
||||
{ATTR_ENTITY_ID: entity_id},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*volume_down_command)
|
||||
|
||||
|
||||
async def test_main_mute_controls(
|
||||
hass: HomeAssistant, mock_receiver: MockReceiver
|
||||
) -> None:
|
||||
"""Test mute state and controls for the main zone."""
|
||||
state = hass.states.get(MAIN_ENTITY_ID)
|
||||
|
||||
assert state.attributes[ATTR_MEDIA_VOLUME_MUTED] is False
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: True},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call("MU", "ON")
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_VOLUME_MUTE,
|
||||
{ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_MEDIA_VOLUME_MUTED: False},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call("MU", "OFF")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"zone",
|
||||
"entity_id",
|
||||
"initial_source",
|
||||
"updated_source",
|
||||
"expected_source",
|
||||
"select_source_command",
|
||||
),
|
||||
[
|
||||
("main", MAIN_ENTITY_ID, "cd", InputSource.NET, "net", ("SI", "NET")),
|
||||
(
|
||||
"zone_2",
|
||||
ZONE_2_ENTITY_ID,
|
||||
"tuner",
|
||||
InputSource.BT,
|
||||
"bt",
|
||||
("Z2", "BT"),
|
||||
),
|
||||
("zone_3", ZONE_3_ENTITY_ID, None, InputSource.DVD, "dvd", ("Z1", "DVD")),
|
||||
],
|
||||
)
|
||||
async def test_source_state_and_controls(
|
||||
hass: HomeAssistant,
|
||||
mock_receiver: MockReceiver,
|
||||
zone: ZoneName,
|
||||
entity_id: str,
|
||||
initial_source: str | None,
|
||||
updated_source: InputSource,
|
||||
expected_source: str,
|
||||
select_source_command: tuple[str, str],
|
||||
) -> None:
|
||||
"""Test source state and selection for each zone."""
|
||||
entity_state = hass.states.get(entity_id)
|
||||
|
||||
assert entity_state.attributes.get(ATTR_INPUT_SOURCE) == initial_source
|
||||
|
||||
source_list = entity_state.attributes[ATTR_INPUT_SOURCE_LIST]
|
||||
assert "cd" in source_list
|
||||
assert "dvd" in source_list
|
||||
assert "tuner" in source_list
|
||||
assert source_list == sorted(source_list)
|
||||
|
||||
state = _default_state()
|
||||
zone_state = state.get_zone(zone)
|
||||
zone_state.power = True
|
||||
zone_state.input_source = updated_source
|
||||
mock_receiver.mock_state(state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(entity_id).attributes[ATTR_INPUT_SOURCE] == expected_source
|
||||
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_INPUT_SOURCE: expected_source},
|
||||
blocking=True,
|
||||
)
|
||||
assert mock_receiver._send_command.await_args == call(*select_source_command)
|
||||
|
||||
|
||||
async def test_main_invalid_source_raises(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test invalid main-zone sources raise an error."""
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
MP_DOMAIN,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
{
|
||||
ATTR_ENTITY_ID: MAIN_ENTITY_ID,
|
||||
ATTR_INPUT_SOURCE: "NONEXISTENT",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
def test_input_source_translation_keys_cover_all_enum_members() -> None:
|
||||
"""Test all input sources have a declared translation key."""
|
||||
assert set(INPUT_SOURCE_DENON_TO_HA) == set(InputSource)
|
||||
|
||||
strings = json.loads(STRINGS_PATH.read_text("utf-8"))
|
||||
assert set(INPUT_SOURCE_DENON_TO_HA.values()) == set(
|
||||
strings["entity"]["media_player"]["receiver"]["state_attributes"]["source"][
|
||||
"state"
|
||||
]
|
||||
)
|
||||
130
tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json
Normal file
130
tests/components/tuya/fixtures/cs_biflejkeshx1sqig.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"endpoint": "https://apigw.tuyaeu.com",
|
||||
"mqtt_connected": true,
|
||||
"disabled_by": null,
|
||||
"disabled_polling": false,
|
||||
"name": "D825A I",
|
||||
"category": "cs",
|
||||
"product_id": "biflejkeshx1sqig",
|
||||
"product_name": "D825A",
|
||||
"online": true,
|
||||
"sub": false,
|
||||
"time_zone": "+01:00",
|
||||
"active_time": "2024-11-21T16:23:08+00:00",
|
||||
"create_time": "2024-11-21T16:23:08+00:00",
|
||||
"update_time": "2024-11-21T16:23:08+00:00",
|
||||
"function": {
|
||||
"switch": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"dehumidify_set_enum": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["40", "45", "50"]
|
||||
}
|
||||
},
|
||||
"fan_speed_enum": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["low", "mid", "high", "auto"]
|
||||
}
|
||||
},
|
||||
"swing": {
|
||||
"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_enum": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["40", "45", "50"]
|
||||
}
|
||||
},
|
||||
"fan_speed_enum": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["low", "mid", "high", "auto"]
|
||||
}
|
||||
},
|
||||
"humidity_indoor": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"unit": "%",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"temp_indoor": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"unit": "℃",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"swing": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"child_lock": {
|
||||
"type": "Boolean",
|
||||
"value": {}
|
||||
},
|
||||
"countdown_set": {
|
||||
"type": "Enum",
|
||||
"value": {
|
||||
"range": ["cancel", "1h", "2h", "3h"]
|
||||
}
|
||||
},
|
||||
"countdown_left": {
|
||||
"type": "Integer",
|
||||
"value": {
|
||||
"unit": "min",
|
||||
"min": 0,
|
||||
"max": 540,
|
||||
"scale": 0,
|
||||
"step": 1
|
||||
}
|
||||
},
|
||||
"fault": {
|
||||
"type": "Bitmap",
|
||||
"value": {
|
||||
"label": ["C1", "C2", "P1", "P3", "P4", "water_full"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"switch": true,
|
||||
"dehumidify_set_enum": "45",
|
||||
"fan_speed_enum": "auto",
|
||||
"humidity_indoor": 76,
|
||||
"temp_indoor": 24,
|
||||
"swing": true,
|
||||
"child_lock": false,
|
||||
"countdown_set": "cancel",
|
||||
"countdown_left": 0,
|
||||
"fault": 0
|
||||
},
|
||||
"set_up": true,
|
||||
"support_local": true
|
||||
}
|
||||
@@ -234,6 +234,63 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[fan.d825a_i-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'preset_modes': None,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'fan',
|
||||
'entity_category': None,
|
||||
'entity_id': 'fan.d825a_i',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 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': <FanEntityFeature: 49>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'tuya.giqs1xhsekjelfibsc',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[fan.d825a_i-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'D825A I',
|
||||
'percentage': 100,
|
||||
'percentage_step': 25.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': None,
|
||||
'supported_features': <FanEntityFeature: 49>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'fan.d825a_i',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[fan.dehumidifer-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -57,6 +57,64 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[humidifier.d825a_i-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'max_humidity': 100,
|
||||
'min_humidity': 0,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'humidifier',
|
||||
'entity_category': None,
|
||||
'entity_id': 'humidifier.d825a_i',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <HumidifierDeviceClass.DEHUMIDIFIER: 'dehumidifier'>,
|
||||
'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.giqs1xhsekjelfibscswitch',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[humidifier.d825a_i-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_humidity': 76,
|
||||
'device_class': 'dehumidifier',
|
||||
'friendly_name': 'D825A I',
|
||||
'max_humidity': 100,
|
||||
'min_humidity': 0,
|
||||
'supported_features': <HumidifierEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'humidifier.d825a_i',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[humidifier.dehumidifer-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -4060,6 +4060,37 @@
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_registry[giqs1xhsekjelfibsc]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'tuya',
|
||||
'giqs1xhsekjelfibsc',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Tuya',
|
||||
'model': 'D825A',
|
||||
'model_id': 'biflejkeshx1sqig',
|
||||
'name': 'D825A I',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_registry[gjnpc0eojd]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
|
||||
@@ -2004,6 +2004,130 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[select.d825a_i_countdown-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'cancel',
|
||||
'1h',
|
||||
'2h',
|
||||
'3h',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.d825a_i_countdown',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Countdown',
|
||||
'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.giqs1xhsekjelfibsccountdown_set',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[select.d825a_i_countdown-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'D825A I Countdown',
|
||||
'options': list([
|
||||
'cancel',
|
||||
'1h',
|
||||
'2h',
|
||||
'3h',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.d825a_i_countdown',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'cancel',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[select.d825a_i_target_humidity-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'40',
|
||||
'45',
|
||||
'50',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'select',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'select.d825a_i_target_humidity',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Target humidity',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Target humidity',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'target_humidity',
|
||||
'unique_id': 'tuya.giqs1xhsekjelfibscdehumidify_set_enum',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[select.d825a_i_target_humidity-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'D825A I Target humidity',
|
||||
'options': list([
|
||||
'40',
|
||||
'45',
|
||||
'50',
|
||||
]),
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'select.d825a_i_target_humidity',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '45',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[select.dehumidifer_countdown-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -5090,6 +5090,119 @@
|
||||
'state': '241.6',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.d825a_i_humidity-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.d825a_i_humidity',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Humidity',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.HUMIDITY: 'humidity'>,
|
||||
'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.giqs1xhsekjelfibschumidity_indoor',
|
||||
'unit_of_measurement': '%',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.d825a_i_humidity-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'humidity',
|
||||
'friendly_name': 'D825A I Humidity',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': '%',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.d825a_i_humidity',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '76.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.d825a_i_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.d825a_i_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'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.giqs1xhsekjelfibsctemp_indoor',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.d825a_i_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'D825A I Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.d825a_i_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '24.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[sensor.dehumidifer_humidity-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -2973,6 +2973,57 @@
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[switch.d825a_i_child_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'switch',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'switch.d825a_i_child_lock',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Child lock',
|
||||
'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.giqs1xhsekjelfibscchild_lock',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[switch.d825a_i_child_lock-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'D825A I Child lock',
|
||||
'icon': 'mdi:account-lock',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'switch.d825a_i_child_lock',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[switch.dehumidifer_child_lock-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# serializer version: 1
|
||||
# name: test_diagnostics
|
||||
dict({
|
||||
'coordinator_data': dict({
|
||||
'door_lock_rules': dict({
|
||||
'door-001': dict({
|
||||
'ended_time': 0,
|
||||
'type': '',
|
||||
}),
|
||||
'door-002': dict({
|
||||
'ended_time': 0,
|
||||
'type': '',
|
||||
}),
|
||||
}),
|
||||
'door_thumbnails': dict({
|
||||
'door-001': dict({
|
||||
'door_thumbnail_last_update': 1700000000,
|
||||
'url': '/preview/front_door.png',
|
||||
}),
|
||||
}),
|
||||
'doors': dict({
|
||||
'door-001': dict({
|
||||
'door_lock_relay_status': 'lock',
|
||||
'door_position_status': 'close',
|
||||
'door_thumbnail': '/preview/front_door.png',
|
||||
'door_thumbnail_last_update': 1700000000,
|
||||
'floor_id': '',
|
||||
'full_name': '',
|
||||
'id': 'door-001',
|
||||
'is_bind_hub': False,
|
||||
'lock_rule_status': None,
|
||||
'name': 'Front Door',
|
||||
'type': 'door',
|
||||
}),
|
||||
'door-002': dict({
|
||||
'door_lock_relay_status': 'unlock',
|
||||
'door_position_status': 'open',
|
||||
'door_thumbnail': None,
|
||||
'door_thumbnail_last_update': None,
|
||||
'floor_id': '',
|
||||
'full_name': '',
|
||||
'id': 'door-002',
|
||||
'is_bind_hub': False,
|
||||
'lock_rule_status': None,
|
||||
'name': 'Back Door',
|
||||
'type': 'door',
|
||||
}),
|
||||
}),
|
||||
'emergency': dict({
|
||||
'evacuation': False,
|
||||
'lockdown': False,
|
||||
}),
|
||||
'lock_rule_support_complete': True,
|
||||
'supports_lock_rules': True,
|
||||
'unconfirmed_lock_rule_doors': list([
|
||||
]),
|
||||
}),
|
||||
'entry_data': dict({
|
||||
'api_token': '**REDACTED**',
|
||||
'host': '192.168.1.1',
|
||||
'verify_ssl': False,
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
29
tests/components/unifi_access/test_diagnostics.py
Normal file
29
tests/components/unifi_access/test_diagnostics.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for the diagnostics data provided by the UniFi Access integration."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
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_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
hass_client: ClientSessionGenerator,
|
||||
mock_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test diagnostics."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
assert (
|
||||
await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry)
|
||||
== snapshot
|
||||
)
|
||||
161
tests/components/valve/test_trigger.py
Normal file
161
tests/components/valve/test_trigger.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Test valve trigger."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.valve import ATTR_IS_CLOSED, DOMAIN, ValveState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components.common import (
|
||||
TriggerStateDescription,
|
||||
assert_trigger_behavior_any,
|
||||
assert_trigger_behavior_first,
|
||||
assert_trigger_behavior_last,
|
||||
assert_trigger_gated_by_labs_flag,
|
||||
parametrize_target_entities,
|
||||
parametrize_trigger_states,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
TRIGGER_STATES = [
|
||||
*parametrize_trigger_states(
|
||||
trigger="valve.closed",
|
||||
target_states=[
|
||||
(ValveState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
(ValveState.CLOSING, {ATTR_IS_CLOSED: True}),
|
||||
(ValveState.OPENING, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
other_states=[
|
||||
(ValveState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
(ValveState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(ValveState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(ValveState.OPEN, {ATTR_IS_CLOSED: None}),
|
||||
(ValveState.OPEN, {}),
|
||||
],
|
||||
),
|
||||
*parametrize_trigger_states(
|
||||
trigger="valve.opened",
|
||||
target_states=[
|
||||
(ValveState.OPEN, {ATTR_IS_CLOSED: False}),
|
||||
(ValveState.OPENING, {ATTR_IS_CLOSED: False}),
|
||||
(ValveState.CLOSING, {ATTR_IS_CLOSED: False}),
|
||||
],
|
||||
other_states=[
|
||||
(ValveState.CLOSED, {ATTR_IS_CLOSED: True}),
|
||||
],
|
||||
extra_invalid_states=[
|
||||
(ValveState.CLOSED, {ATTR_IS_CLOSED: None}),
|
||||
(ValveState.CLOSED, {}),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_valves(hass: HomeAssistant) -> dict[str, list[str]]:
|
||||
"""Create multiple valve entities associated with different targets."""
|
||||
return await target_entities(hass, DOMAIN)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"trigger_key",
|
||||
[
|
||||
"valve.closed",
|
||||
"valve.opened",
|
||||
],
|
||||
)
|
||||
async def test_valve_triggers_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
|
||||
) -> None:
|
||||
"""Test the valve triggers are gated by the labs flag."""
|
||||
await assert_trigger_gated_by_labs_flag(hass, caplog, trigger_key)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES)
|
||||
async def test_valve_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_valves: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any] | None,
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the valve state trigger fires when any valve state changes to a specific state."""
|
||||
await assert_trigger_behavior_any(
|
||||
hass,
|
||||
target_entities=target_valves,
|
||||
trigger_target_config=trigger_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
trigger=trigger,
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES)
|
||||
async def test_valve_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
target_valves: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entities_in_target: int,
|
||||
entity_id: str,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the valve state trigger fires when the first valve changes to a specific state."""
|
||||
await assert_trigger_behavior_first(
|
||||
hass,
|
||||
target_entities=target_valves,
|
||||
trigger_target_config=trigger_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
trigger=trigger,
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities(DOMAIN),
|
||||
)
|
||||
@pytest.mark.parametrize(("trigger", "trigger_options", "states"), TRIGGER_STATES)
|
||||
async def test_valve_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
target_valves: dict[str, list[str]],
|
||||
trigger_target_config: dict,
|
||||
entities_in_target: int,
|
||||
entity_id: str,
|
||||
trigger: str,
|
||||
trigger_options: dict[str, Any],
|
||||
states: list[TriggerStateDescription],
|
||||
) -> None:
|
||||
"""Test that the valve state trigger fires when the last valve changes to a specific state."""
|
||||
await assert_trigger_behavior_last(
|
||||
hass,
|
||||
target_entities=target_valves,
|
||||
trigger_target_config=trigger_target_config,
|
||||
entity_id=entity_id,
|
||||
entities_in_target=entities_in_target,
|
||||
trigger=trigger,
|
||||
trigger_options=trigger_options,
|
||||
states=states,
|
||||
)
|
||||
Reference in New Issue
Block a user