Compare commits

..

12 Commits

Author SHA1 Message Date
abmantis
c27fe91d74 Simplify claude's integrations skill 2026-03-30 19:12:29 +01:00
Raphael Hehl
d6458bc574 Add diagnostics support to UniFi Access integration (#166819)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-30 12:39:38 +02:00
Mike O'Driscoll
434f1dca2c Add diagnostics to Casper Glow (#166807) 2026-03-30 12:38:28 +02:00
Florian
c6ad6da6ae Clamp surepetcare battery percentage to 0-100 (#166824)
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-30 12:34:38 +02:00
epenet
be3d65538d Use runtime_data in motion_blinds integration (#166849)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 12:32:27 +02:00
Michael
297e9e265a Add valve.opened and valve.closed triggers (#165160) 2026-03-30 12:06:43 +02:00
Simone Chemelli
119dfbddea Update quality scale for Fritz (#166853) 2026-03-30 11:32:16 +02:00
Jeef
970925141e Bump weatherflow4py to 1.5.2 (#166773) 2026-03-30 10:54:17 +02:00
Matthias Alphart
51131beaec Update knx-frontend to 2026.3.28.223133 (#166764) 2026-03-30 10:44:16 +02:00
Manu
c509226d17 Remove unused string from HTML5 integration (#166826) 2026-03-30 09:03:37 +02:00
epenet
067a9a0c25 Bump tuya-device-handlers to 0.0.16 (#166844) 2026-03-30 08:51:50 +02:00
pedroterzero
d10197d535 Add fixture for Tuya D825A dehumidifier (#166822) 2026-03-30 07:12:57 +02:00
55 changed files with 1116 additions and 2278 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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
View File

@@ -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

View File

@@ -1,5 +1,5 @@
{
"domain": "denon",
"name": "Denon",
"integrations": ["denon", "denonavr", "denon_rs232", "heos"]
"integrations": ["denon", "denonavr", "heos"]
}

View File

@@ -193,6 +193,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"todo",
"update",
"vacuum",
"valve",
"water_heater",
"window",
}

View 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,
),
}

View File

@@ -39,7 +39,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: No network discovery.

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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]

View File

@@ -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"]
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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"
}
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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.",

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 (

View File

@@ -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()

View File

@@ -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"

View File

@@ -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(

View File

@@ -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:

View File

@@ -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))

View File

@@ -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

View File

@@ -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"
]
}

View 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()
},
},
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: todo
discovery: todo
docs-data-update: todo

View File

@@ -40,5 +40,13 @@
"toggle": {
"service": "mdi:valve-open"
}
},
"triggers": {
"closed": {
"trigger": "mdi:valve-closed"
},
"opened": {
"trigger": "mdi:valve-open"
}
}
}

View File

@@ -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"
}
}
}

View 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

View 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

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["weatherflow4py"],
"requirements": ["weatherflow4py==1.4.1"]
"requirements": ["weatherflow4py==1.5.2"]
}

View File

@@ -142,7 +142,6 @@ FLOWS = {
"deconz",
"decora_wifi",
"deluge",
"denon_rs232",
"denonavr",
"devialet",
"devolo_home_control",

View File

@@ -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,

View File

@@ -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
View File

@@ -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

View File

@@ -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

View 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,
}),
})
# ---

View 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")
)

View File

@@ -1,4 +0,0 @@
"""Tests for the Denon RS232 integration."""
MOCK_DEVICE = "/dev/ttyUSB0"
MOCK_MODEL = "avr_3805"

View File

@@ -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()

View File

@@ -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"

View File

@@ -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"
]
)

View 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
}

View File

@@ -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([

View File

@@ -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([

View File

@@ -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,

View File

@@ -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([

View File

@@ -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([

View File

@@ -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([

View File

@@ -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,
}),
})
# ---

View 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
)

View 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,
)