Compare commits

..

1 Commits

Author SHA1 Message Date
abmantis
c27fe91d74 Simplify claude's integrations skill 2026-03-30 19:12:29 +01:00
93 changed files with 479 additions and 2666 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

View File

@@ -579,7 +579,6 @@ homeassistant.components.trmnl.*
homeassistant.components.tts.*
homeassistant.components.twentemilieu.*
homeassistant.components.unifi.*
homeassistant.components.unifi_access.*
homeassistant.components.unifiprotect.*
homeassistant.components.upcloud.*
homeassistant.components.update.*

4
CODEOWNERS generated
View File

@@ -741,8 +741,8 @@ build.json @home-assistant/supervisor
/tests/components/honeywell/ @rdfurman @mkmer
/homeassistant/components/hr_energy_qube/ @MattieGit
/tests/components/hr_energy_qube/ @MattieGit
/homeassistant/components/html5/ @alexyao2015 @tr4nt0r
/tests/components/html5/ @alexyao2015 @tr4nt0r
/homeassistant/components/html5/ @alexyao2015
/tests/components/html5/ @alexyao2015
/homeassistant/components/http/ @home-assistant/core
/tests/components/http/ @home-assistant/core
/homeassistant/components/huawei_lte/ @scop @fphammerle

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from dataclasses import dataclass
from python_homeassistant_analytics import (
Environment,
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
@@ -39,7 +38,7 @@ async def async_setup_entry(
client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass))
try:
integrations = await client.get_integrations(Environment.NEXT)
integrations = await client.get_integrations()
except HomeassistantAnalyticsConnectionError as ex:
raise ConfigEntryNotReady("Could not fetch integration list") from ex

View File

@@ -124,7 +124,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"battery",
"calendar",
"climate",
"counter",
"cover",
"device_tracker",
"door",

View File

@@ -15,5 +15,5 @@
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "silver",
"requirements": ["pycasperglow==1.2.0"]
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -1,15 +0,0 @@
"""Provides conditions for counters."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.condition import Condition, make_entity_numerical_condition
DOMAIN = "counter"
CONDITIONS: dict[str, type[Condition]] = {
"is_value": make_entity_numerical_condition(DOMAIN),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for counters."""
return CONDITIONS

View File

@@ -1,25 +0,0 @@
is_value:
target:
entity:
- domain: counter
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
threshold:
required: true
selector:
numeric_threshold:
entity:
- domain: counter
- domain: input_number
- domain: number
mode: is
number:
mode: box

View File

@@ -1,9 +1,4 @@
{
"conditions": {
"is_value": {
"condition": "mdi:counter"
}
},
"services": {
"decrement": {
"service": "mdi:numeric-negative-1"

View File

@@ -3,22 +3,6 @@
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_value": {
"description": "Tests the value of one or more counters.",
"fields": {
"behavior": {
"description": "How the state should match on the targeted counters.",
"name": "Behavior"
},
"threshold": {
"description": "What to test for and threshold values.",
"name": "Threshold"
}
},
"name": "Counter value"
}
},
"entity_component": {
"_": {
"name": "[%key:component::counter::title%]",
@@ -46,12 +30,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",

View File

@@ -45,13 +45,6 @@ SUPPORT_FLAGS_HEATER = (
)
def _operation_mode_to_ha(mode: WaterHeaterOperationMode | None) -> str:
"""Translate an EcoNet operation mode to a Home Assistant state."""
if mode in (None, WaterHeaterOperationMode.VACATION):
return STATE_OFF
return ECONET_STATE_TO_HA[mode]
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
@@ -87,22 +80,26 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
@property
def current_operation(self) -> str:
"""Return current operation."""
return _operation_mode_to_ha(self.water_heater.mode)
econet_mode = self.water_heater.mode
_current_op = STATE_OFF
if econet_mode is not None:
_current_op = ECONET_STATE_TO_HA[econet_mode]
return _current_op
@property
def operation_list(self) -> list[str]:
"""List of available operation modes."""
return list(
dict.fromkeys(
ECONET_STATE_TO_HA[mode]
for mode in self.water_heater.modes
if mode
not in (
WaterHeaterOperationMode.UNKNOWN,
WaterHeaterOperationMode.VACATION,
)
)
)
econet_modes = self.water_heater.modes
operation_modes = set()
for mode in econet_modes:
if (
mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION
):
ha_mode = ECONET_STATE_TO_HA[mode]
operation_modes.add(ha_mode)
return list(operation_modes)
@property
def supported_features(self) -> WaterHeaterEntityFeature:

View File

@@ -10,7 +10,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.helpers.httpx_client import get_async_client
from .const import DOMAIN, UPNP_AVAILABLE
@@ -41,7 +40,6 @@ class FingConfigFlow(ConfigFlow, domain=DOMAIN):
ip=user_input[CONF_IP_ADDRESS],
port=int(user_input[CONF_PORT]),
key=user_input[CONF_API_KEY],
client=get_async_client(self.hass),
)
try:

View File

@@ -11,7 +11,6 @@ import httpx
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_IP_ADDRESS, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPNP_AVAILABLE
@@ -39,7 +38,6 @@ class FingDataUpdateCoordinator(DataUpdateCoordinator[FingDataObject]):
ip=config_entry.data[CONF_IP_ADDRESS],
port=int(config_entry.data[CONF_PORT]),
key=config_entry.data[CONF_API_KEY],
client=get_async_client(hass),
)
self._upnp_available = config_entry.data[UPNP_AVAILABLE]
update_interval = timedelta(seconds=30)

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["fing_agent_api==1.1.0"]
"requirements": ["fing_agent_api==1.0.3"]
}

View File

@@ -68,5 +68,5 @@ rules:
# Platinum
async-dependency: todo
inject-websession: done
inject-websession: todo
strict-typing: todo

View File

@@ -27,36 +27,15 @@ from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import instance_id
from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
TextSelector,
)
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_PRODUCT_NAME,
CONF_PRODUCT_TYPE,
CONF_SERIAL,
CONF_USAGE,
DOMAIN,
ENERGY_MONITORING_DEVICES,
LOGGER,
)
USAGE_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=["consumption", "generation"],
translation_key="usage",
mode=SelectSelectorMode.LIST,
)
)
from .const import CONF_PRODUCT_NAME, CONF_PRODUCT_TYPE, CONF_SERIAL, DOMAIN, LOGGER
class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for HomeWizard devices."""
"""Handle a config flow for P1 meter."""
VERSION = 1
@@ -64,8 +43,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
product_name: str | None = None
product_type: str | None = None
serial: str | None = None
token: str | None = None
usage: str | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -87,12 +64,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
f"{device_info.product_type}_{device_info.serial}"
)
self._abort_if_unique_id_configured(updates=user_input)
if device_info.product_type in ENERGY_MONITORING_DEVICES:
self.ip_address = user_input[CONF_IP_ADDRESS]
self.product_name = device_info.product_name
self.product_type = device_info.product_type
self.serial = device_info.serial
return await self.async_step_usage()
return self.async_create_entry(
title=f"{device_info.product_name}",
data=user_input,
@@ -111,45 +82,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_usage(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step where we ask how the energy monitor is used."""
assert self.ip_address
assert self.product_name
assert self.product_type
assert self.serial
data: dict[str, Any] = {CONF_IP_ADDRESS: self.ip_address}
if self.token:
data[CONF_TOKEN] = self.token
if user_input is not None:
return self.async_create_entry(
title=f"{self.product_name}",
data=data | user_input,
)
return self.async_show_form(
step_id="usage",
data_schema=vol.Schema(
{
vol.Required(
CONF_USAGE,
default=user_input.get(CONF_USAGE)
if user_input is not None
else "consumption",
): USAGE_SELECTOR,
}
),
description_placeholders={
CONF_PRODUCT_NAME: self.product_name,
CONF_PRODUCT_TYPE: self.product_type,
CONF_SERIAL: self.serial,
CONF_IP_ADDRESS: self.ip_address,
},
)
async def async_step_authorize(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -169,7 +101,8 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
# Now we got a token, we can ask for some more info
device_info = await HomeWizardEnergyV2(self.ip_address, token=token).device()
async with HomeWizardEnergyV2(self.ip_address, token=token) as api:
device_info = await api.device()
data = {
CONF_IP_ADDRESS: self.ip_address,
@@ -180,14 +113,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
f"{device_info.product_type}_{device_info.serial}"
)
self._abort_if_unique_id_configured(updates=data)
self.product_name = device_info.product_name
self.product_type = device_info.product_type
self.serial = device_info.serial
if device_info.product_type in ENERGY_MONITORING_DEVICES:
self.token = token
return await self.async_step_usage()
return self.async_create_entry(
title=f"{device_info.product_name}",
data=data,
@@ -214,8 +139,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured(
updates={CONF_IP_ADDRESS: discovery_info.host}
)
if self.product_type in ENERGY_MONITORING_DEVICES:
return await self.async_step_usage()
return await self.async_step_discovery_confirm()

View File

@@ -5,8 +5,6 @@ from __future__ import annotations
from datetime import timedelta
import logging
from homewizard_energy.const import Model
from homeassistant.const import Platform
DOMAIN = "homewizard"
@@ -24,14 +22,5 @@ LOGGER = logging.getLogger(__package__)
CONF_PRODUCT_NAME = "product_name"
CONF_PRODUCT_TYPE = "product_type"
CONF_SERIAL = "serial"
CONF_USAGE = "usage"
UPDATE_INTERVAL = timedelta(seconds=5)
ENERGY_MONITORING_DEVICES = (
Model.ENERGY_SOCKET,
Model.ENERGY_METER_1_PHASE,
Model.ENERGY_METER_3_PHASE,
Model.ENERGY_METER_EASTRON_SDM230,
Model.ENERGY_METER_EASTRON_SDM630,
)

View File

@@ -39,7 +39,7 @@ from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .const import CONF_USAGE, DOMAIN, ENERGY_MONITORING_DEVICES
from .const import DOMAIN
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
@@ -267,6 +267,15 @@ SENSORS: Final[tuple[HomeWizardSensorEntityDescription, ...]] = (
enabled_fn=lambda data: data.measurement.energy_export_t4_kwh != 0,
value_fn=lambda data: data.measurement.energy_export_t4_kwh or None,
),
HomeWizardSensorEntityDescription(
key="active_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
has_fn=lambda data: data.measurement.power_w is not None,
value_fn=lambda data: data.measurement.power_w,
),
HomeWizardSensorEntityDescription(
key="active_power_l1_w",
translation_key="active_power_phase_w",
@@ -692,30 +701,22 @@ async def async_setup_entry(
entry: HomeWizardConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Cleanup deleted entrity registry item."""
"""Initialize sensors."""
# Initialize default sensors
entities: list = [
HomeWizardSensorEntity(entry.runtime_data, description)
for description in SENSORS
if description.has_fn(entry.runtime_data.data)
]
active_power_sensor_description = HomeWizardSensorEntityDescription(
key="active_power_w",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
entity_registry_enabled_default=(
entry.runtime_data.data.device.product_type != Model.BATTERY
and entry.data.get(CONF_USAGE, "consumption") == "consumption"
),
has_fn=lambda x: True,
value_fn=lambda data: data.measurement.power_w,
)
# Add optional production power sensor for supported energy monitoring devices
# or plug-in battery
if entry.runtime_data.data.device.product_type in (
*ENERGY_MONITORING_DEVICES,
Model.ENERGY_SOCKET,
Model.ENERGY_METER_1_PHASE,
Model.ENERGY_METER_3_PHASE,
Model.ENERGY_METER_EASTRON_SDM230,
Model.ENERGY_METER_EASTRON_SDM630,
Model.BATTERY,
):
active_prodution_power_sensor_description = HomeWizardSensorEntityDescription(
@@ -735,26 +736,16 @@ async def async_setup_entry(
is not None
and total_export > 0
)
or entry.data.get(CONF_USAGE, "consumption") == "generation"
),
has_fn=lambda x: True,
value_fn=lambda data: (
power_w * -1 if (power_w := data.measurement.power_w) else power_w
),
)
entities.extend(
(
HomeWizardSensorEntity(
entry.runtime_data, active_power_sensor_description
),
HomeWizardSensorEntity(
entry.runtime_data, active_prodution_power_sensor_description
),
)
)
elif (data := entry.runtime_data.data) and data.measurement.power_w is not None:
entities.append(
HomeWizardSensorEntity(entry.runtime_data, active_power_sensor_description)
HomeWizardSensorEntity(
entry.runtime_data, active_prodution_power_sensor_description
)
)
# Initialize external devices

View File

@@ -41,16 +41,6 @@
},
"description": "Update configuration for {title}."
},
"usage": {
"data": {
"usage": "Usage"
},
"data_description": {
"usage": "This will enable either a power consumption or power production sensor the first time this device is set up."
},
"description": "What are you going to monitor with your {product_name} ({product_type} {serial} at {ip_address})?",
"title": "Usage"
},
"user": {
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]"
@@ -209,13 +199,5 @@
},
"title": "Update the authentication method for {title}"
}
},
"selector": {
"usage": {
"options": {
"consumption": "Monitoring consumed energy",
"generation": "Monitoring generated energy"
}
}
}
}

View File

@@ -1,7 +1,7 @@
{
"domain": "html5",
"name": "HTML5 Push Notifications",
"codeowners": ["@alexyao2015", "@tr4nt0r"],
"codeowners": ["@alexyao2015"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/html5",

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
from typing import Any
from huum.const import SaunaStatus
@@ -17,10 +18,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP, DOMAIN
from .const import CONFIG_DEFAULT_MAX_TEMP, CONFIG_DEFAULT_MIN_TEMP
from .coordinator import HuumConfigEntry, HuumDataUpdateCoordinator
from .entity import HuumBaseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
@@ -110,7 +113,5 @@ class HuumDevice(HuumBaseEntity, ClimateEntity):
try:
await self.coordinator.huum.turn_on(temperature)
except (ValueError, SafetyException) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_turn_on",
) from err
_LOGGER.error(str(err))
raise HomeAssistantError(f"Unable to turn on sauna: {err}") from err

View File

@@ -56,6 +56,5 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
return await self.huum.status()
except (Forbidden, NotAuthenticated) as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_failed",
"Could not log in to Huum with given credentials"
) from err

View File

@@ -62,7 +62,7 @@ rules:
status: exempt
comment: All entities are core functionality.
entity-translations: done
exception-translations: done
exception-translations: todo
icon-translations: done
reconfiguration-flow: todo
repair-issues:

View File

@@ -45,13 +45,5 @@
"name": "[%key:component::sensor::entity_component::humidity::name%]"
}
}
},
"exceptions": {
"auth_failed": {
"message": "Could not log in to Huum with the given credentials."
},
"unable_to_turn_on": {
"message": "Unable to turn on the sauna."
}
}
}

View File

@@ -73,45 +73,31 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
except HTTPError as error:
raise UpdateFailed from error
# Fetch last hour of data
for sensor in self.devices:
try:
try:
# Fetch last hour of data
for sensor in self.devices:
data = await self.api.get_sensor_status(
sensor=sensor,
tz=self.hass.config.time_zone,
)
except HTTPError as error:
error_data = error.args[1] if len(error.args) > 1 else None
if (
isinstance(error_data, dict)
and error_data.get("error") == "no_readings"
):
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
_LOGGER.debug("Got data: %s", data)
_LOGGER.debug("Got data: %s", data)
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
if data_error := data.get("error"):
if data_error == "no_readings":
sensor.data = None
_LOGGER.debug("No readings for %s", sensor.name)
continue
_LOGGER.debug("Error: %s", data_error)
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
)
sensor.data = data["data"]["current"]
current_data = data.get("data", {}).get("current")
if current_data is None:
sensor.data = None
_LOGGER.debug("No current data payload for %s", sensor.name)
continue
sensor.data = current_data
except HTTPError as error:
raise UpdateFailed(
translation_domain=DOMAIN, translation_key="update_error"
) from error
# Verify that we have permission to read the sensors
for sensor in self.devices:

View File

@@ -6,14 +6,19 @@ from meteofrance_api.client import MeteoFranceClient
from meteofrance_api.helpers import is_valid_warning_department
from requests import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import DOMAIN, PLATFORMS
from .const import (
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
PLATFORMS,
)
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceConfigEntry,
MeteoFranceData,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@@ -21,7 +26,7 @@ from .coordinator import (
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Meteo-France account from a config entry."""
hass.data.setdefault(DOMAIN, {})
@@ -86,27 +91,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeteoFranceConfigEntry)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
if coordinator_rain and not coordinator_rain.last_update_success:
coordinator_rain = None
if coordinator_alert and not coordinator_alert.last_update_success:
coordinator_alert = None
entry.runtime_data = MeteoFranceData(
forecast_coordinator=coordinator_forecast,
rain_coordinator=coordinator_rain,
alert_coordinator=coordinator_alert,
)
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR_FORECAST: coordinator_forecast,
}
if coordinator_rain and coordinator_rain.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_RAIN] = coordinator_rain
if coordinator_alert and coordinator_alert.last_update_success:
hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT] = coordinator_alert
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if entry.runtime_data.alert_coordinator:
department = entry.runtime_data.forecast_coordinator.data.position.get("dept")
if hass.data[DOMAIN][entry.entry_id][COORDINATOR_ALERT]:
department = hass.data[DOMAIN][entry.entry_id][
COORDINATOR_FORECAST
].data.position.get("dept")
hass.data[DOMAIN][department] = False
_LOGGER.debug(
(
@@ -118,14 +121,13 @@ async def async_unload_entry(
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
if not hass.data[DOMAIN]:
hass.data.pop(DOMAIN)
return unload_ok
async def _async_update_listener(
hass: HomeAssistant, entry: MeteoFranceConfigEntry
) -> None:
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -23,6 +23,9 @@ from homeassistant.const import Platform
DOMAIN = "meteo_france"
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
COORDINATOR_FORECAST = "coordinator_forecast"
COORDINATOR_RAIN = "coordinator_rain"
COORDINATOR_ALERT = "coordinator_alert"
ATTRIBUTION = "Data provided by Météo-France"
MODEL = "Météo-France mobile API"
MANUFACTURER = "Météo-France"

View File

@@ -1,8 +1,5 @@
"""Support for Meteo-France weather data."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
@@ -16,18 +13,6 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type MeteoFranceConfigEntry = ConfigEntry[MeteoFranceData]
@dataclass
class MeteoFranceData:
"""Data for the Meteo-France integration."""
forecast_coordinator: MeteoFranceForecastUpdateCoordinator
rain_coordinator: MeteoFranceRainUpdateCoordinator | None
alert_coordinator: MeteoFranceAlertUpdateCoordinator | None
SCAN_INTERVAL_RAIN = timedelta(minutes=5)
SCAN_INTERVAL = timedelta(minutes=15)
@@ -35,12 +20,12 @@ SCAN_INTERVAL = timedelta(minutes=15)
class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
"""Coordinator for Meteo-France forecast data."""
config_entry: MeteoFranceConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MeteoFranceConfigEntry,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -65,12 +50,12 @@ class MeteoFranceForecastUpdateCoordinator(DataUpdateCoordinator[Forecast]):
class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
"""Coordinator for Meteo-France rain data."""
config_entry: MeteoFranceConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MeteoFranceConfigEntry,
entry: ConfigEntry,
client: MeteoFranceClient,
) -> None:
"""Initialize the coordinator."""
@@ -95,12 +80,12 @@ class MeteoFranceRainUpdateCoordinator(DataUpdateCoordinator[Rain]):
class MeteoFranceAlertUpdateCoordinator(DataUpdateCoordinator[CurrentPhenomenons]):
"""Coordinator for Meteo-France alert data."""
config_entry: MeteoFranceConfigEntry
config_entry: ConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: MeteoFranceConfigEntry,
entry: ConfigEntry,
client: MeteoFranceClient,
department: str,
) -> None:

View File

@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UV_INDEX,
@@ -40,11 +41,18 @@ from .const import (
ATTR_NEXT_RAIN_1_HOUR_FORECAST,
ATTR_NEXT_RAIN_DT_REF,
ATTRIBUTION,
COORDINATOR_ALERT,
COORDINATOR_FORECAST,
COORDINATOR_RAIN,
DOMAIN,
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceAlertUpdateCoordinator, MeteoFranceConfigEntry
from .coordinator import (
MeteoFranceAlertUpdateCoordinator,
MeteoFranceForecastUpdateCoordinator,
MeteoFranceRainUpdateCoordinator,
)
@dataclass(frozen=True, kw_only=True)
@@ -180,13 +188,20 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MeteoFranceConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France sensor platform."""
coordinator_forecast = entry.runtime_data.forecast_coordinator
coordinator_rain = entry.runtime_data.rain_coordinator
coordinator_alert = entry.runtime_data.alert_coordinator
data = hass.data[DOMAIN][entry.entry_id]
coordinator_forecast: MeteoFranceForecastUpdateCoordinator = data[
COORDINATOR_FORECAST
]
coordinator_rain: MeteoFranceRainUpdateCoordinator | None = data.get(
COORDINATOR_RAIN
)
coordinator_alert: MeteoFranceAlertUpdateCoordinator | None = data.get(
COORDINATOR_ALERT
)
entities: list[MeteoFranceSensor[Any]] = [
MeteoFranceSensor(coordinator_forecast, description)

View File

@@ -18,6 +18,7 @@ from homeassistant.components.weather import (
WeatherEntity,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_MODE,
UnitOfPrecipitationDepth,
@@ -34,13 +35,14 @@ from homeassistant.util import dt as dt_util
from .const import (
ATTRIBUTION,
CONDITION_MAP,
COORDINATOR_FORECAST,
DOMAIN,
FORECAST_MODE_DAILY,
FORECAST_MODE_HOURLY,
MANUFACTURER,
MODEL,
)
from .coordinator import MeteoFranceConfigEntry, MeteoFranceForecastUpdateCoordinator
from .coordinator import MeteoFranceForecastUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -56,11 +58,13 @@ def format_condition(condition: str, force_day: bool = False) -> str:
async def async_setup_entry(
hass: HomeAssistant,
entry: MeteoFranceConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator = entry.runtime_data.forecast_coordinator
coordinator: MeteoFranceForecastUpdateCoordinator = hass.data[DOMAIN][
entry.entry_id
][COORDINATOR_FORECAST]
async_add_entities(
[

View File

@@ -45,7 +45,7 @@ from homeassistant.components.webhook import (
async_register as webhook_register,
async_unregister as webhook_unregister,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME, CONF_URL, CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -80,7 +80,7 @@ from .const import (
WEB_HOOK_SENTINEL_KEY,
WEB_HOOK_SENTINEL_VALUE,
)
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .coordinator import MotionEyeUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [CAMERA_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN]
@@ -134,7 +134,7 @@ def is_acceptable_camera(camera: dict[str, Any] | None) -> bool:
@callback
def listen_for_new_cameras(
hass: HomeAssistant,
entry: MotionEyeConfigEntry,
entry: ConfigEntry,
add_func: Callable,
) -> None:
"""Listen for new cameras."""
@@ -168,7 +168,7 @@ def _add_camera(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
client: MotionEyeClient,
entry: MotionEyeConfigEntry,
entry: ConfigEntry,
camera_id: int,
camera: dict[str, Any],
device_identifier: tuple[str, str],
@@ -274,8 +274,9 @@ def _add_camera(
)
async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up motionEye from a config entry."""
hass.data.setdefault(DOMAIN, {})
client = create_motioneye_client(
entry.data[CONF_URL],
@@ -305,7 +306,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) ->
)
coordinator = MotionEyeUpdateCoordinator(hass, entry, client)
entry.runtime_data = coordinator
hass.data[DOMAIN][entry.entry_id] = coordinator
current_cameras: set[tuple[str, str]] = set()
device_registry = dr.async_get(hass)
@@ -361,13 +362,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) ->
return True
async def async_unload_entry(hass: HomeAssistant, entry: MotionEyeConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID])
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
await entry.runtime_data.client.async_client_close()
coordinator = hass.data[DOMAIN].pop(entry.entry_id)
await coordinator.client.async_client_close()
return unload_ok
@@ -436,14 +438,10 @@ def _get_media_event_data(
event_file_type: int,
) -> dict[str, str]:
config_entry_id = next(iter(device.config_entries), None)
if (
not config_entry_id
or not (entry := hass.config_entries.async_get_entry(config_entry_id))
or entry.state != ConfigEntryState.LOADED
):
if not config_entry_id or config_entry_id not in hass.data[DOMAIN]:
return {}
coordinator: MotionEyeUpdateCoordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][config_entry_id]
client = coordinator.client
for identifier in device.identifiers:

View File

@@ -30,6 +30,7 @@ from homeassistant.components.mjpeg import (
CONF_STILL_IMAGE_URL,
MjpegCamera,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_AUTHENTICATION,
CONF_NAME,
@@ -49,13 +50,14 @@ from .const import (
CONF_STREAM_URL_TEMPLATE,
CONF_SURVEILLANCE_PASSWORD,
CONF_SURVEILLANCE_USERNAME,
DOMAIN,
MOTIONEYE_MANUFACTURER,
SERVICE_ACTION,
SERVICE_SET_TEXT_OVERLAY,
SERVICE_SNAPSHOT,
TYPE_MOTIONEYE_MJPEG_CAMERA,
)
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
PLATFORMS = [Platform.CAMERA]
@@ -90,11 +92,11 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionEyeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlowWithReload,
@@ -38,7 +39,6 @@ from .const import (
DEFAULT_WEBHOOK_SET_OVERWRITE,
DOMAIN,
)
from .coordinator import MotionEyeConfigEntry
class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -180,7 +180,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
config_entry: MotionEyeConfigEntry,
config_entry: ConfigEntry,
) -> MotionEyeOptionsFlow:
"""Get the Hyperion Options flow."""
return MotionEyeOptionsFlow()

View File

@@ -16,16 +16,13 @@ from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
type MotionEyeConfigEntry = ConfigEntry[MotionEyeUpdateCoordinator]
class MotionEyeUpdateCoordinator(DataUpdateCoordinator[dict[str, Any] | None]):
"""Coordinator for motionEye data."""
config_entry: MotionEyeConfigEntry
config_entry: ConfigEntry
def __init__(
self, hass: HomeAssistant, entry: MotionEyeConfigEntry, client: MotionEyeClient
self, hass: HomeAssistant, entry: ConfigEntry, client: MotionEyeClient
) -> None:
"""Initialize the coordinator."""
super().__init__(

View File

@@ -17,13 +17,12 @@ from homeassistant.components.media_source import (
PlayMedia,
Unresolvable,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from . import get_media_url, split_motioneye_device_identifier
from .const import DOMAIN
from .coordinator import MotionEyeConfigEntry
MIME_TYPE_MAP = {
"movies": "video/mp4",
@@ -75,7 +74,7 @@ class MotionEyeMediaSource(MediaSource):
self._verify_kind_or_raise(kind)
url = get_media_url(
config.runtime_data.client,
self.hass.data[DOMAIN][config.entry_id].client,
self._get_camera_id_or_raise(config, device),
self._get_path_or_raise(path),
kind == "images",
@@ -121,10 +120,10 @@ class MotionEyeMediaSource(MediaSource):
return self._build_media_devices(config)
return self._build_media_configs()
def _get_config_or_raise(self, config_id: str) -> MotionEyeConfigEntry:
def _get_config_or_raise(self, config_id: str) -> ConfigEntry:
"""Get a config entry from a URL."""
entry = self.hass.config_entries.async_get_entry(config_id)
if not entry or entry.state != ConfigEntryState.LOADED:
if not entry:
raise MediaSourceError(f"Unable to find config entry with id: {config_id}")
return entry
@@ -155,7 +154,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _get_camera_id_or_raise(
cls, config: MotionEyeConfigEntry, device: dr.DeviceEntry
cls, config: ConfigEntry, device: dr.DeviceEntry
) -> int:
"""Get a config entry from a URL."""
for identifier in device.identifiers:
@@ -165,7 +164,7 @@ class MotionEyeMediaSource(MediaSource):
raise MediaSourceError(f"Could not find camera id for device id: {device.id}")
@classmethod
def _build_media_config(cls, config: MotionEyeConfigEntry) -> BrowseMediaSource:
def _build_media_config(cls, config: ConfigEntry) -> BrowseMediaSource:
return BrowseMediaSource(
domain=DOMAIN,
identifier=config.entry_id,
@@ -197,7 +196,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_device(
cls,
config: MotionEyeConfigEntry,
config: ConfigEntry,
device: dr.DeviceEntry,
full_title: bool = True,
) -> BrowseMediaSource:
@@ -212,7 +211,7 @@ class MotionEyeMediaSource(MediaSource):
children_media_class=MediaClass.DIRECTORY,
)
def _build_media_devices(self, config: MotionEyeConfigEntry) -> BrowseMediaSource:
def _build_media_devices(self, config: ConfigEntry) -> BrowseMediaSource:
"""Build the media sources for device entries."""
device_registry = dr.async_get(self.hass)
devices = dr.async_entries_for_config_entry(device_registry, config.entry_id)
@@ -227,7 +226,7 @@ class MotionEyeMediaSource(MediaSource):
@classmethod
def _build_media_kind(
cls,
config: MotionEyeConfigEntry,
config: ConfigEntry,
device: dr.DeviceEntry,
kind: str,
full_title: bool = True,
@@ -252,7 +251,7 @@ class MotionEyeMediaSource(MediaSource):
)
def _build_media_kinds(
self, config: MotionEyeConfigEntry, device: dr.DeviceEntry
self, config: ConfigEntry, device: dr.DeviceEntry
) -> BrowseMediaSource:
base = self._build_media_device(config, device)
base.children = [
@@ -263,7 +262,7 @@ class MotionEyeMediaSource(MediaSource):
async def _build_media_path(
self,
config: MotionEyeConfigEntry,
config: ConfigEntry,
device: dr.DeviceEntry,
kind: str,
path: str,
@@ -277,7 +276,7 @@ class MotionEyeMediaSource(MediaSource):
base.children = []
client = config.runtime_data.client
client = self.hass.data[DOMAIN][config.entry_id].client
camera_id = self._get_camera_id_or_raise(config, device)
if kind == "movies":
@@ -287,7 +286,7 @@ class MotionEyeMediaSource(MediaSource):
sub_dirs: set[str] = set()
parts = parsed_path.parts
media_list = resp.get(KEY_MEDIA_LIST, []) if resp else []
media_list = resp.get(KEY_MEDIA_LIST, [])
def get_media_sort_key(media: dict) -> str:
"""Get media sort key."""

View File

@@ -9,23 +9,24 @@ from motioneye_client.client import MotionEyeClient
from motioneye_client.const import KEY_ACTIONS
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .const import DOMAIN, TYPE_MOTIONEYE_ACTION_SENSOR
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionEyeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -16,13 +16,14 @@ from motioneye_client.const import (
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import get_camera_from_cameras, listen_for_new_cameras
from .const import TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeConfigEntry, MotionEyeUpdateCoordinator
from .const import DOMAIN, TYPE_MOTIONEYE_SWITCH_BASE
from .coordinator import MotionEyeUpdateCoordinator
from .entity import MotionEyeEntity
MOTIONEYE_SWITCHES = [
@@ -67,11 +68,11 @@ MOTIONEYE_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionEyeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
coordinator = entry.runtime_data
coordinator = hass.data[DOMAIN][entry.entry_id]
@callback
def camera_add(camera: dict[str, Any]) -> None:

View File

@@ -311,19 +311,6 @@ def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Plat
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the actions and websocket API for the MQTT component."""
if config.get(DOMAIN) and not mqtt_config_entry_enabled(hass):
issue_registry = ir.async_get(hass)
issue_registry.async_get_or_create(
DOMAIN,
"yaml_setup_without_active_setup",
is_fixable=False,
is_persistent=False,
severity=ir.IssueSeverity.WARNING,
learn_more_url="https://www.home-assistant.io/integrations/mqtt/"
"#configuration",
translation_key="yaml_setup_without_active_setup",
)
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_mqtt_info)

View File

@@ -140,7 +140,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
"entity_registry_enabled_default",
"extra_state_attributes",
"force_update",
"group_entities",
"icon",
"friendly_name",
"should_poll",

View File

@@ -1141,10 +1141,6 @@
}
},
"title": "MQTT device \"{name}\" subentry migration to YAML"
},
"yaml_setup_without_active_setup": {
"description": "Home Assistant detected manually configured MQTT items, but these items cannot be loaded because MQTT is not set up correctly. Make sure the MQTT broker is set up correctly, or remove the MQTT configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.",
"title": "MQTT is not set up correctly"
}
},
"options": {

View File

@@ -24,14 +24,12 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
from homeassistant.helpers.typing import ConfigType
from . import api
from .const import DOMAIN
from .const import DOMAIN, NEATO_LOGIN
from .hub import NeatoHub
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
type NeatoConfigEntry = ConfigEntry[NeatoHub]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BUTTON,
@@ -48,8 +46,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up config entry."""
hass.data.setdefault(DOMAIN, {})
if CONF_TOKEN not in entry.data:
raise ConfigEntryAuthFailed
@@ -70,6 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> boo
raise ConfigEntryNotReady from ex
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
hass.data[DOMAIN][entry.entry_id] = neato_session
hub = NeatoHub(hass, Account(neato_session))
await hub.async_update_entry_unique_id(entry)
@@ -80,13 +80,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> boo
_LOGGER.debug("Failed to connect to Neato API")
raise ConfigEntryNotReady from ex
entry.runtime_data = hub
hass.data[NEATO_LOGIN] = hub
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: NeatoConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View File

@@ -5,21 +5,22 @@ from __future__ import annotations
from pybotvac import Robot
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NeatoConfigEntry
from .const import NEATO_ROBOTS
from .entity import NeatoEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NeatoConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato button from config entry."""
entities = [NeatoDismissAlertButton(robot) for robot in entry.runtime_data.robots]
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
async_add_entities(entities, True)

View File

@@ -11,11 +11,11 @@ from pybotvac.robot import Robot
from urllib3.response import HTTPResponse
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -27,14 +27,15 @@ ATTR_GENERATED_AT = "generated_at"
async def async_setup_entry(
hass: HomeAssistant,
entry: NeatoConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato camera with config entry."""
hub = entry.runtime_data
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
dev = [
NeatoCleaningMap(hub, robot, hub.map_data)
for robot in hub.robots
NeatoCleaningMap(neato, robot, mapdata)
for robot in hass.data[NEATO_ROBOTS]
if "maps" in robot.traits
]
@@ -50,7 +51,9 @@ class NeatoCleaningMap(NeatoEntity, Camera):
_attr_translation_key = "cleaning_map"
def __init__(self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any]) -> None:
def __init__(
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
) -> None:
"""Initialize Neato cleaning map."""
super().__init__(robot)
Camera.__init__(self)

View File

@@ -3,6 +3,10 @@
DOMAIN = "neato"
CONF_VENDOR = "vendor"
NEATO_LOGIN = "neato_login"
NEATO_MAP_DATA = "neato_map_data"
NEATO_PERSISTENT_MAPS = "neato_persistent_maps"
NEATO_ROBOTS = "neato_robots"
SCAN_INTERVAL_MINUTES = 1

View File

@@ -1,10 +1,7 @@
"""Support for Neato botvac connected vacuum cleaners."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from pybotvac import Account
from urllib3.response import HTTPResponse
@@ -13,6 +10,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.util import Throttle
from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS
_LOGGER = logging.getLogger(__name__)
@@ -23,17 +22,14 @@ class NeatoHub:
"""Initialize the Neato hub."""
self._hass = hass
self.my_neato: Account = neato
self.robots: set[Any] = set()
self.persistent_maps: dict[str, Any] = {}
self.map_data: dict[str, Any] = {}
@Throttle(timedelta(minutes=1))
def update_robots(self) -> None:
"""Update the robot states."""
_LOGGER.debug("Running HUB.update_robots %s", self.robots)
self.robots = self.my_neato.robots
self.persistent_maps = self.my_neato.persistent_maps
self.map_data = self.my_neato.maps
_LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS))
self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
def download_map(self, url: str) -> HTTPResponse:
"""Download a new map image."""

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -28,12 +28,12 @@ BATTERY = "Battery"
async def async_setup_entry(
hass: HomeAssistant,
entry: NeatoConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Neato sensor using config entry."""
hub = entry.runtime_data
dev = [NeatoSensor(hub, robot) for robot in hub.robots]
neato: NeatoHub = hass.data[NEATO_LOGIN]
dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]]
if not dev:
return

View File

@@ -10,12 +10,12 @@ from pybotvac.exceptions import NeatoRobotException
from pybotvac.robot import Robot
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NeatoConfigEntry
from .const import SCAN_INTERVAL_MINUTES
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -30,14 +30,14 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
async def async_setup_entry(
hass: HomeAssistant,
entry: NeatoConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato switch with config entry."""
hub = entry.runtime_data
neato: NeatoHub = hass.data[NEATO_LOGIN]
dev = [
NeatoConnectedSwitch(hub, robot, type_name)
for robot in hub.robots
NeatoConnectedSwitch(neato, robot, type_name)
for robot in hass.data[NEATO_ROBOTS]
for type_name in SWITCH_TYPES
]

View File

@@ -15,12 +15,22 @@ from homeassistant.components.vacuum import (
VacuumActivity,
VacuumEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NeatoConfigEntry
from .const import ACTION, ALERTS, ERRORS, MODE, SCAN_INTERVAL_MINUTES
from .const import (
ACTION,
ALERTS,
ERRORS,
MODE,
NEATO_LOGIN,
NEATO_MAP_DATA,
NEATO_PERSISTENT_MAPS,
NEATO_ROBOTS,
SCAN_INTERVAL_MINUTES,
)
from .entity import NeatoEntity
from .hub import NeatoHub
@@ -42,16 +52,16 @@ ATTR_LAUNCHED_FROM = "launched_from"
async def async_setup_entry(
hass: HomeAssistant,
entry: NeatoConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato vacuum with config entry."""
hub = entry.runtime_data
neato: NeatoHub = hass.data[NEATO_LOGIN]
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
dev = [
NeatoConnectedVacuum(
hub, robot, hub.map_data or None, hub.persistent_maps or None
)
for robot in hub.robots
NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
for robot in hass.data[NEATO_ROBOTS]
]
if not dev:

View File

@@ -346,9 +346,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
id=event.item.id,
tool_name="web_search_call",
tool_args={
"action": event.item.action.to_dict()
if event.item.action
else None,
"action": event.item.action.to_dict(),
},
external=True,
)
@@ -362,10 +360,6 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
}
last_role = "tool_result"
elif isinstance(event.item, ImageGenerationCall):
if last_summary_index is not None:
yield {"role": "assistant"}
last_role = "assistant"
last_summary_index = None
yield {"native": event.item}
last_summary_index = -1 # Trigger new assistant message on next turn
elif isinstance(event, ResponseTextDeltaEvent):

View File

@@ -11,7 +11,6 @@ import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
@@ -164,16 +163,7 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
if (
reconfigure_entry.state is not ConfigEntryState.LOADED
or reconfigure_entry.data != user_input
):
if not await self.test_connection(
user_input[CONF_HOST], user_input[CONF_PORT]
):
errors["base"] = "cannot_connect"
if not errors:
if await self.test_connection(user_input[CONF_HOST], user_input[CONF_PORT]):
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={
@@ -181,8 +171,11 @@ class SatelConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_PORT: user_input[CONF_PORT],
},
title=user_input[CONF_HOST],
reload_even_if_entry_is_unchanged=False,
)
errors["base"] = "cannot_connect"
suggested_values: dict[str, Any] = {
**reconfigure_entry.data,
**(user_input or {}),

View File

@@ -34,13 +34,15 @@ class SynologyDSMbuttonDescription(ButtonEntityDescription):
BUTTONS: Final = [
SynologyDSMbuttonDescription(
key="reboot",
name="Reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_reboot,
),
SynologyDSMbuttonDescription(
key="shutdown",
translation_key="shutdown",
name="Shutdown",
icon="mdi:power",
entity_category=EntityCategory.CONFIG,
press_action=lambda syno_api: syno_api.async_shutdown,
),
@@ -61,7 +63,6 @@ class SynologyDSMButton(ButtonEntity):
"""Defines a Synology DSM button."""
entity_description: SynologyDSMbuttonDescription
_attr_has_entity_name = True
def __init__(
self,
@@ -74,6 +75,7 @@ class SynologyDSMButton(ButtonEntity):
if TYPE_CHECKING:
assert api.network is not None
assert api.information is not None
self._attr_name = f"{api.network.hostname} {description.name}"
self._attr_unique_id = f"{api.information.serial}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, api.information.serial)}

View File

@@ -1,10 +1,5 @@
{
"entity": {
"button": {
"shutdown": {
"default": "mdi:power"
}
},
"sensor": {
"cpu_15min_load": {
"default": "mdi:chip"

View File

@@ -76,11 +76,6 @@
"name": "Security status"
}
},
"button": {
"shutdown": {
"name": "Shutdown"
}
},
"sensor": {
"cpu_15min_load": {
"name": "CPU load average (15 min)"

View File

@@ -37,6 +37,8 @@ async def async_setup_bot_platform(
pushbot = PushBot(hass, bot, config, secret_token)
await pushbot.start_application()
webhook_registered = await pushbot.register_webhook()
if not webhook_registered:
raise RuntimeError("Failed to register webhook with Telegram")
@@ -47,8 +49,6 @@ async def async_setup_bot_platform(
get_base_url(bot),
)
await pushbot.start_application()
hass.http.register_view(
PushBotView(
hass,

View File

@@ -60,14 +60,14 @@
},
"services": {
"set_value": {
"description": "Sets the value of a text entity.",
"description": "Sets the value.",
"fields": {
"value": {
"description": "Enter your text.",
"name": "Value"
}
},
"name": "Set text value"
"name": "Set value"
}
},
"title": "Text",

View File

@@ -1 +1 @@
"""The Thomson integration."""
"""The thomson component."""

View File

@@ -4,13 +4,14 @@ from __future__ import annotations
from typing import Any
from tuya_device_handlers.helpers.diagnostics import customer_device_as_dict
from tuya_device_handlers.device_wrapper import DEVICE_WARNINGS
from tuya_sharing import CustomerDevice
from homeassistant.components.diagnostics import REDACTED
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.util import dt as dt_util
from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
@@ -78,13 +79,52 @@ def _async_device_as_dict(
) -> dict[str, Any]:
"""Represent a Tuya device as a dictionary."""
# Base device information
data = customer_device_as_dict(device)
# Base device information, without sensitive information.
data = {
"id": device.id,
"name": device.name,
"category": device.category,
"product_id": device.product_id,
"product_name": device.product_name,
"online": device.online,
"sub": device.sub,
"time_zone": device.time_zone,
"active_time": dt_util.utc_from_timestamp(device.active_time).isoformat(),
"create_time": dt_util.utc_from_timestamp(device.create_time).isoformat(),
"update_time": dt_util.utc_from_timestamp(device.update_time).isoformat(),
"function": {},
"status_range": {},
"status": {},
"home_assistant": {},
"set_up": device.set_up,
"support_local": device.support_local,
"local_strategy": device.local_strategy,
"warnings": DEVICE_WARNINGS.get(device.id),
}
# Redact sensitive information.
for key in data["status"]:
if key in _REDACTED_DPCODES:
data["status"][key] = REDACTED
# Gather Tuya states
for dpcode, value in device.status.items():
# These statuses may contain sensitive information, redact these..
if dpcode in _REDACTED_DPCODES:
data["status"][dpcode] = REDACTED
continue
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
data["function"][function.code] = {
"type": function.type,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": status_range.values,
"report_type": status_range.report_type,
}
# Gather information how this Tuya device is represented in Home Assistant
device_registry = dr.async_get(hass)

View File

@@ -1,45 +0,0 @@
"""UniFi Network data update coordinator."""
from __future__ import annotations
from datetime import timedelta
from typing import TYPE_CHECKING
from aiounifi.interfaces.api_handlers import APIHandler
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import LOGGER
if TYPE_CHECKING:
from .hub.hub import UnifiHub
POLL_INTERVAL = timedelta(seconds=10)
class UnifiDataUpdateCoordinator[HandlerT: APIHandler](DataUpdateCoordinator[None]):
"""Coordinator managing polling for a single UniFi API data source."""
def __init__(
self,
hub: UnifiHub,
handler: HandlerT,
) -> None:
"""Initialize coordinator."""
super().__init__(
hub.hass,
LOGGER,
name=f"UniFi {type(handler).__name__}",
config_entry=hub.config.entry,
update_interval=POLL_INTERVAL,
)
self._handler = handler
@property
def handler(self) -> HandlerT:
"""Return the aiounifi handler managed by this coordinator."""
return self._handler
async def _async_update_data(self) -> None:
"""Update data from the API handler."""
await self._handler.update()

View File

@@ -94,14 +94,16 @@ def async_client_device_info_fn(hub: UnifiHub, obj_id: str) -> DeviceInfo:
@dataclass(frozen=True, kw_only=True)
class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescription):
class UnifiEntityDescription[HandlerT: APIHandler, ApiItemT: ApiItem](
EntityDescription
):
"""UniFi Entity Description."""
api_handler_fn: Callable[[aiounifi.Controller], HandlerT]
"""Provide api_handler from api."""
device_info_fn: Callable[[UnifiHub, str], DeviceInfo | None]
"""Provide device info object based on hub and obj_id."""
object_fn: Callable[[aiounifi.Controller, str], ItemT]
object_fn: Callable[[aiounifi.Controller, str], ApiItemT]
"""Retrieve object based on api and obj_id."""
unique_id_fn: Callable[[UnifiHub, str], str]
"""Provide a unique ID based on hub and obj_id."""
@@ -111,7 +113,7 @@ class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescrip
"""Determine if config entry options allow creation of entity."""
available_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: hub.available
"""Determine if entity is available, default is if connection is working."""
name_fn: Callable[[ItemT], str | None] = lambda obj: None
name_fn: Callable[[ApiItemT], str | None] = lambda obj: None
"""Entity name function, can be used to extend entity name beyond device name."""
supported_fn: Callable[[UnifiHub, str], bool] = lambda hub, obj_id: True
"""Determine if UniFi object supports providing relevant data for entity."""
@@ -127,17 +129,17 @@ class UnifiEntityDescription[HandlerT: APIHandler, ItemT: ApiItem](EntityDescrip
"""If entity needs to do regular checks on state."""
class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity):
class UnifiEntity[HandlerT: APIHandler, ApiItemT: ApiItem](Entity):
"""Representation of a UniFi entity."""
entity_description: UnifiEntityDescription[HandlerT, ItemT]
entity_description: UnifiEntityDescription[HandlerT, ApiItemT]
_attr_unique_id: str
def __init__(
self,
obj_id: str,
hub: UnifiHub,
description: UnifiEntityDescription[HandlerT, ItemT],
description: UnifiEntityDescription[HandlerT, ApiItemT],
) -> None:
"""Set up UniFi switch entity."""
self._obj_id = obj_id
@@ -256,11 +258,6 @@ class UnifiEntity[HandlerT: APIHandler, ItemT: ApiItem](Entity):
"""
self.async_update_state(ItemEvent.ADDED, self._obj_id)
@callback
def get_object(self) -> ItemT:
"""Return the latest object for this entity."""
return self.entity_description.object_fn(self.api, self._obj_id)
@callback
@abstractmethod
def async_update_state(self, event: ItemEvent, obj_id: str) -> None:

View File

@@ -12,28 +12,30 @@ from datetime import timedelta
from functools import partial
from typing import TYPE_CHECKING, Any
from aiounifi.interfaces.api_handlers import APIHandler, ItemEvent
from aiounifi.interfaces.api_handlers import ItemEvent
from homeassistant.const import Platform
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from ..const import LOGGER, UNIFI_WIRELESS_CLIENTS
from ..coordinator import UnifiDataUpdateCoordinator
from ..entity import UnifiEntity, UnifiEntityDescription
if TYPE_CHECKING:
from .. import UnifiConfigEntry
from .hub import UnifiHub
CHECK_HEARTBEAT_INTERVAL = timedelta(seconds=1)
POLL_INTERVAL = timedelta(seconds=10)
class UnifiEntityLoader:
"""UniFi Network integration handling platforms for entity registration."""
def __init__(self, hub: UnifiHub) -> None:
def __init__(self, hub: UnifiHub, config_entry: UnifiConfigEntry) -> None:
"""Initialize the UniFi entity loader."""
self.hub = hub
self.api_updaters = (
@@ -46,20 +48,28 @@ class UnifiEntityLoader:
hub.api.sites.update,
hub.api.system_information.update,
hub.api.firewall_policies.update,
hub.api.traffic_rules.update,
hub.api.traffic_routes.update,
hub.api.wlans.update,
)
self.polling_api_updaters = (
hub.api.traffic_rules.update,
hub.api.traffic_routes.update,
)
self.wireless_clients = hub.hass.data[UNIFI_WIRELESS_CLIENTS]
self._polling_coordinators: dict[int, UnifiDataUpdateCoordinator] = {
id(hub.api.traffic_rules): UnifiDataUpdateCoordinator(
hub, hub.api.traffic_rules
),
id(hub.api.traffic_routes): UnifiDataUpdateCoordinator(
hub, hub.api.traffic_routes
),
}
for coordinator in self._polling_coordinators.values():
coordinator.async_add_listener(lambda: None)
self._data_update_coordinator = DataUpdateCoordinator(
hub.hass,
LOGGER,
name="Unifi entity poller",
config_entry=config_entry,
update_method=self._update_pollable_api_data,
update_interval=POLL_INTERVAL,
)
self._update_listener = self._data_update_coordinator.async_add_listener(
update_callback=lambda: None
)
self.platforms: list[
tuple[
@@ -75,15 +85,7 @@ class UnifiEntityLoader:
async def initialize(self) -> None:
"""Initialize API data and extra client support."""
await asyncio.gather(
self._refresh_api_data(),
self._refresh_data(
[
coordinator.async_refresh
for coordinator in self._polling_coordinators.values()
]
),
)
await self._refresh_api_data()
self._restore_inactive_clients()
self.wireless_clients.update_clients(set(self.hub.api.clients.values()))
@@ -98,6 +100,10 @@ class UnifiEntityLoader:
if result is not None:
LOGGER.warning("Exception on update %s", result)
async def _update_pollable_api_data(self) -> None:
"""Refresh API data for pollable updaters."""
await self._refresh_data(self.polling_api_updaters)
async def _refresh_api_data(self) -> None:
"""Refresh API data from network application."""
await self._refresh_data(self.api_updaters)
@@ -159,13 +165,6 @@ class UnifiEntityLoader:
and description.supported_fn(self.hub, obj_id)
)
@callback
def get_data_update_coordinator(
self, handler: APIHandler
) -> UnifiDataUpdateCoordinator | None:
"""Return the polling coordinator for a handler, if available."""
return self._polling_coordinators.get(id(handler))
@callback
def _load_entities(
self,

View File

@@ -39,7 +39,7 @@ class UnifiHub:
self.hass = hass
self.api = api
self.config = UnifiConfig.from_config_entry(config_entry)
self.entity_loader = UnifiEntityLoader(self)
self.entity_loader = UnifiEntityLoader(self, config_entry)
self._entity_helper = UnifiEntityHelper(hass, api)
self.websocket = UnifiWebsocket(hass, api, self.signal_reachable)

View File

@@ -208,6 +208,8 @@ async def async_traffic_rule_control_fn(
"""Control traffic rule state."""
traffic_rule = hub.api.traffic_rules[obj_id].raw
await hub.api.request(TrafficRuleEnableRequest.create(traffic_rule, target))
# Update the traffic rules so the UI is updated appropriately
await hub.api.traffic_rules.update()
async def async_traffic_route_control_fn(
@@ -216,6 +218,8 @@ async def async_traffic_route_control_fn(
"""Control traffic route state."""
traffic_route = hub.api.traffic_routes[obj_id].raw
await hub.api.request(TrafficRouteSaveRequest.create(traffic_route, target))
# Update the traffic routes so the UI is updated appropriately
await hub.api.traffic_routes.update()
async def async_wlan_control_fn(hub: UnifiHub, obj_id: str, target: bool) -> None:
@@ -443,18 +447,10 @@ class UnifiSwitchEntity[HandlerT: APIHandler, ApiItemT: ApiItem](
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on switch."""
await self.entity_description.control_fn(self.hub, self._obj_id, True)
if coordinator := self.hub.entity_loader.get_data_update_coordinator(
self.entity_description.api_handler_fn(self.api)
):
await coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off switch."""
await self.entity_description.control_fn(self.hub, self._obj_id, False)
if coordinator := self.hub.entity_loader.get_data_update_coordinator(
self.entity_description.api_handler_fn(self.api)
):
await coordinator.async_request_refresh()
@callback
def async_update_state(
@@ -468,7 +464,7 @@ class UnifiSwitchEntity[HandlerT: APIHandler, ApiItemT: ApiItem](
return
description = self.entity_description
obj = self.get_object()
obj = description.object_fn(self.api, self._obj_id)
if (is_on := description.is_on_fn(self.hub, obj)) != self.is_on:
self._attr_is_on = is_on

View File

@@ -24,29 +24,6 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def _validate_input(self, user_input: dict[str, Any]) -> dict[str, str]:
"""Validate user input and return errors dict."""
errors: dict[str, str] = {}
session = async_get_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = UnifiAccessApiClient(
host=user_input[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=user_input[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return errors
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -54,9 +31,26 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
errors = await self._validate_input(user_input)
if not errors:
session = async_get_clientsession(
self.hass, verify_ssl=user_input[CONF_VERIFY_SSL]
)
client = UnifiAccessApiClient(
host=user_input[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=user_input[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
return self.async_create_entry(
title="UniFi Access",
data=user_input,
@@ -74,40 +68,6 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(
{CONF_HOST: user_input[CONF_HOST]},
)
errors = await self._validate_input(user_input)
if not errors:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates=user_input,
)
suggested_values = user_input or dict(reconfigure_entry.data)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_API_TOKEN): str,
vol.Required(CONF_VERIFY_SSL): bool,
}
),
suggested_values,
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -122,13 +82,25 @@ class UnifiAccessConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input is not None:
errors = await self._validate_input(
{
**reauth_entry.data,
CONF_API_TOKEN: user_input[CONF_API_TOKEN],
}
session = async_get_clientsession(
self.hass, verify_ssl=reauth_entry.data[CONF_VERIFY_SSL]
)
if not errors:
client = UnifiAccessApiClient(
host=reauth_entry.data[CONF_HOST],
api_token=user_input[CONF_API_TOKEN],
session=session,
verify_ssl=reauth_entry.data[CONF_VERIFY_SSL],
)
try:
await client.authenticate()
except ApiAuthError:
errors["base"] = "invalid_auth"
except ApiConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]},

View File

@@ -333,7 +333,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
async def _handle_setting_update(self, msg: WebsocketMessage) -> None:
"""Handle settings update messages (evacuation/lockdown)."""
if self.data is None:
return # type: ignore[unreachable]
return
update = cast(SettingUpdate, msg)
self.async_set_updated_data(
replace(

View File

@@ -58,11 +58,11 @@ rules:
entity-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done
strict-typing: todo

View File

@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -20,19 +19,6 @@
},
"description": "The API token for UniFi Access at {host} is invalid. Please provide a new token."
},
"reconfigure": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",
"host": "[%key:common::config_flow::data::host%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_token": "[%key:component::unifi_access::config::step::user::data_description::api_token%]",
"host": "[%key:component::unifi_access::config::step::user::data_description::host%]",
"verify_ssl": "[%key:component::unifi_access::config::step::user::data_description::verify_ssl%]"
},
"description": "Update the connection settings of this UniFi Access controller."
},
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]",

View File

@@ -1040,14 +1040,9 @@ class Entity(
self._async_verify_state_writable()
self._async_write_ha_state()
@final
@callback
def async_write_ha_state(self) -> None:
"""Write the state to the state machine.
Note: Integrations which need to customize state write should
override _async_write_ha_state, not this method.
"""
"""Write the state to the state machine."""
if not self.hass or not self._verified_state_writable:
self._async_verify_state_writable()
if self.hass.loop_thread_id != threading.get_ident():

10
mypy.ini generated
View File

@@ -5548,16 +5548,6 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.unifi_access.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.unifiprotect.*]
check_untyped_defs = true
disallow_incomplete_defs = true

4
requirements_all.txt generated
View File

@@ -963,7 +963,7 @@ feedparser==6.0.12
file-read-backwards==2.0.0
# homeassistant.components.fing
fing_agent_api==1.1.0
fing_agent_api==1.0.3
# homeassistant.components.fints
fints==3.1.0
@@ -2001,7 +2001,7 @@ pybravia==0.4.1
pycarwings2==2.14
# homeassistant.components.casper_glow
pycasperglow==1.2.0
pycasperglow==1.1.0
# homeassistant.components.cloudflare
pycfdns==3.0.0

View File

@@ -854,7 +854,7 @@ feedparser==6.0.12
file-read-backwards==2.0.0
# homeassistant.components.fing
fing_agent_api==1.1.0
fing_agent_api==1.0.3
# homeassistant.components.fints
fints==3.1.0
@@ -1732,7 +1732,7 @@ pybotvac==0.0.28
pybravia==0.4.1
# homeassistant.components.casper_glow
pycasperglow==1.2.0
pycasperglow==1.1.0
# homeassistant.components.cloudflare
pycfdns==3.0.0

View File

@@ -153,7 +153,7 @@ async def test_select_ignores_remaining_time_updates(
fire_callbacks: Callable[[GlowState], None],
) -> None:
"""Test that callbacks with only remaining time do not change the select state."""
fire_callbacks(GlowState(dimming_time_remaining_ms=44))
fire_callbacks(GlowState(dimming_time_minutes=44))
state = hass.states.get(ENTITY_ID)
assert state is not None

View File

@@ -1,177 +0,0 @@
"""Test counter conditions."""
from typing import Any
import pytest
from homeassistant.core import HomeAssistant
from tests.components.common import (
ConditionStateDescription,
assert_condition_behavior_all,
assert_condition_behavior_any,
assert_condition_gated_by_labs_flag,
parametrize_condition_states_all,
parametrize_condition_states_any,
parametrize_target_entities,
target_entities,
)
@pytest.fixture
async def target_counters(hass: HomeAssistant) -> dict[str, list[str]]:
"""Create multiple counter entities associated with different targets."""
return await target_entities(hass, "counter")
async def test_counter_condition_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test the counter condition is gated by the labs flag."""
await assert_condition_gated_by_labs_flag(hass, caplog, "counter.is_value")
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("counter"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_any(
condition="counter.is_value",
condition_options={
"threshold": {"type": "above", "value": {"number": 20}},
},
target_states=["21", "50", "100"],
other_states=["0", "10", "20"],
),
*parametrize_condition_states_any(
condition="counter.is_value",
condition_options={
"threshold": {"type": "below", "value": {"number": 20}},
},
target_states=["0", "10", "19"],
other_states=["20", "50", "100"],
),
*parametrize_condition_states_any(
condition="counter.is_value",
condition_options={
"threshold": {
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 30},
},
},
target_states=["11", "20", "29"],
other_states=["0", "10", "30", "100"],
),
*parametrize_condition_states_any(
condition="counter.is_value",
condition_options={
"threshold": {
"type": "outside",
"value_min": {"number": 10},
"value_max": {"number": 30},
},
},
target_states=["0", "10", "30", "100"],
other_states=["11", "20", "29"],
),
],
)
async def test_counter_is_value_condition_behavior_any(
hass: HomeAssistant,
target_counters: dict[str, list[str]],
condition_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the counter is_value condition with 'any' behavior."""
await assert_condition_behavior_any(
hass,
target_entities=target_counters,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("condition_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("counter"),
)
@pytest.mark.parametrize(
("condition", "condition_options", "states"),
[
*parametrize_condition_states_all(
condition="counter.is_value",
condition_options={
"threshold": {"type": "above", "value": {"number": 20}},
},
target_states=["21", "50", "100"],
other_states=["0", "10", "20"],
),
*parametrize_condition_states_all(
condition="counter.is_value",
condition_options={
"threshold": {"type": "below", "value": {"number": 20}},
},
target_states=["0", "10", "19"],
other_states=["20", "50", "100"],
),
*parametrize_condition_states_all(
condition="counter.is_value",
condition_options={
"threshold": {
"type": "between",
"value_min": {"number": 10},
"value_max": {"number": 30},
},
},
target_states=["11", "20", "29"],
other_states=["0", "10", "30", "100"],
),
*parametrize_condition_states_all(
condition="counter.is_value",
condition_options={
"threshold": {
"type": "outside",
"value_min": {"number": 10},
"value_max": {"number": 30},
},
},
target_states=["0", "10", "30", "100"],
other_states=["11", "20", "29"],
),
],
)
async def test_counter_is_value_condition_behavior_all(
hass: HomeAssistant,
target_counters: dict[str, list[str]],
condition_target_config: dict[str, Any],
entity_id: str,
entities_in_target: int,
condition: str,
condition_options: dict[str, Any],
states: list[ConditionStateDescription],
) -> None:
"""Test the counter is_value condition with 'all' behavior."""
await assert_condition_behavior_all(
hass,
target_entities=target_counters,
condition_target_config=condition_target_config,
entity_id=entity_id,
entities_in_target=entities_in_target,
condition=condition,
condition_options=condition_options,
states=states,
)

View File

@@ -1,16 +0,0 @@
{
"wifi_ssid": "My Wi-Fi",
"wifi_strength": 92,
"total_power_import_t1_kwh": 0.003,
"total_power_export_t1_kwh": 0.0,
"active_power_w": 0.0,
"active_power_l1_w": 0.0,
"active_voltage_v": 228.472,
"active_current_a": 0.273,
"active_apparent_current_a": 0.0,
"active_reactive_current_a": 0.0,
"active_apparent_power_va": 9.0,
"active_reactive_power_var": -9.0,
"active_power_factor": 0.611,
"active_frequency_hz": 50
}

View File

@@ -1,7 +0,0 @@
{
"product_type": "HWE-KWH1",
"product_name": "kWh meter",
"serial": "5c2fafabcdef",
"firmware_version": "5.0103",
"api_version": "v2"
}

View File

@@ -1,3 +0,0 @@
{
"cloud_enabled": true
}

View File

@@ -1,16 +0,0 @@
{
"wifi_ssid": "My Wi-Fi",
"wifi_strength": 100,
"total_power_import_kwh": 0.003,
"total_power_import_t1_kwh": 0.003,
"total_power_export_kwh": 0.0,
"total_power_export_t1_kwh": 0.0,
"active_power_w": 0.0,
"active_power_l1_w": 0.0,
"active_voltage_v": 231.539,
"active_current_a": 0.0,
"active_reactive_power_var": 0.0,
"active_apparent_power_va": 0.0,
"active_power_factor": 0.0,
"active_frequency_hz": 50.005
}

View File

@@ -1,7 +0,0 @@
{
"product_type": "HWE-SKT",
"product_name": "Energy Socket",
"serial": "5c2fafabcdef",
"firmware_version": "4.07",
"api_version": "v1"
}

View File

@@ -1,5 +0,0 @@
{
"power_on": true,
"switch_lock": false,
"brightness": 255
}

View File

@@ -1,3 +0,0 @@
{
"cloud_enabled": true
}

View File

@@ -92,7 +92,56 @@
'version': 1,
})
# ---
# name: test_manual_flow_works[HWE-P1]
# name: test_discovery_flow_works
FlowResultSnapshot({
'context': dict({
'confirm_only': True,
'dismiss_protected': True,
'source': 'zeroconf',
'title_placeholders': dict({
'name': 'Energy Socket (5c2fafabcdef)',
}),
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_manual_flow_works
FlowResultSnapshot({
'context': dict({
'source': 'user',
@@ -136,238 +185,3 @@
'version': 1,
})
# ---
# name: test_manual_flow_works_device_energy_monitoring[consumption-HWE-SKT-21]
FlowResultSnapshot({
'context': dict({
'source': 'user',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'consumption',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'consumption',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_manual_flow_works_device_energy_monitoring[generation-HWE-SKT-21]
FlowResultSnapshot({
'context': dict({
'source': 'user',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'generation',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '2.2.2.2',
'usage': 'generation',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'user',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_power_monitoring_discovery_flow_works[consumption]
FlowResultSnapshot({
'context': dict({
'dismiss_protected': True,
'source': 'zeroconf',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'consumption',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'consumption',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_power_monitoring_discovery_flow_works[generation]
FlowResultSnapshot({
'context': dict({
'dismiss_protected': True,
'source': 'zeroconf',
'unique_id': 'HWE-SKT_5c2fafabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'generation',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
'usage': 'generation',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Energy Socket',
'unique_id': 'HWE-SKT_5c2fafabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Energy Socket',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---
# name: test_water_monitoring_discovery_flow_works
FlowResultSnapshot({
'context': dict({
'confirm_only': True,
'dismiss_protected': True,
'source': 'zeroconf',
'title_placeholders': dict({
'name': 'Watermeter',
}),
'unique_id': 'HWE-WTR_3c39efabcdef',
}),
'data': dict({
'ip_address': '127.0.0.1',
}),
'description': None,
'description_placeholders': None,
'flow_id': <ANY>,
'handler': 'homewizard',
'minor_version': 1,
'options': dict({
}),
'result': ConfigEntrySnapshot({
'data': dict({
'ip_address': '127.0.0.1',
}),
'disabled_by': None,
'discovery_keys': dict({
}),
'domain': 'homewizard',
'entry_id': <ANY>,
'minor_version': 1,
'options': dict({
}),
'pref_disable_new_entities': False,
'pref_disable_polling': False,
'source': 'zeroconf',
'subentries': list([
]),
'title': 'Watermeter',
'unique_id': 'HWE-WTR_3c39efabcdef',
'version': 1,
}),
'subentries': tuple(
),
'title': 'Watermeter',
'type': <FlowResultType.CREATE_ENTRY: 'create_entry'>,
'version': 1,
})
# ---

View File

@@ -24,7 +24,6 @@ from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
async def test_manual_flow_works(
hass: HomeAssistant,
mock_homewizardenergy: MagicMock,
@@ -52,50 +51,12 @@ async def test_manual_flow_works(
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-SKT-21"])
@pytest.mark.parametrize(("usage"), ["consumption", "generation"])
async def test_manual_flow_works_device_energy_monitoring(
hass: HomeAssistant,
mock_homewizardenergy: MagicMock,
mock_setup_entry: AsyncMock,
snapshot: SnapshotAssertion,
usage: str,
) -> None:
"""Test config flow accepts user configuration for energy plug."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usage": usage}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result == snapshot
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_homewizardenergy.close.mock_calls) == 1
assert len(mock_homewizardenergy.device.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
@pytest.mark.parametrize("usage", ["consumption", "generation"])
async def test_power_monitoring_discovery_flow_works(
hass: HomeAssistant, snapshot: SnapshotAssertion, usage: str
async def test_discovery_flow_works(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Test discovery energy monitoring setup flow works."""
"""Test discovery setup flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
@@ -116,42 +77,6 @@ async def test_power_monitoring_discovery_flow_works(
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"usage": usage}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result == snapshot
@pytest.mark.usefixtures("mock_homewizardenergy", "mock_setup_entry")
async def test_water_monitoring_discovery_flow_works(
hass: HomeAssistant, snapshot: SnapshotAssertion
) -> None:
"""Test discovery energy monitoring setup flow works."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_ZEROCONF},
data=ZeroconfServiceInfo(
ip_address=ip_address("127.0.0.1"),
ip_addresses=[ip_address("127.0.0.1")],
port=80,
hostname="watermeter-ddeeff.local.",
type="",
name="",
properties={
"api_enabled": "1",
"path": "/api/v1",
"product_name": "Watermeter",
"product_type": "HWE-WTR",
"serial": "3c39efabcdef",
},
),
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "discovery_confirm"
@@ -695,7 +620,7 @@ async def test_reconfigure_cannot_connect(
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1"])
@pytest.mark.parametrize(("device_fixture"), ["HWE-P1", "HWE-KWH1"])
async def test_manual_flow_works_with_v2_api_support(
hass: HomeAssistant,
mock_homewizardenergy_v2: MagicMock,
@@ -727,70 +652,7 @@ async def test_manual_flow_works_with_v2_api_support(
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
with patch(
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize(("device_fixture"), ["HWE-KWH1"])
async def test_manual_flow_energy_monitoring_works_with_v2_api_support(
hass: HomeAssistant,
mock_homewizardenergy_v2: MagicMock,
mock_setup_entry: AsyncMock,
) -> None:
"""Test config flow accepts user configuration for energy monitoring.
This should trigger authorization when v2 support is detected.
It should ask for usage if a energy monitoring device is configured.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
# Simulate v2 support but not authorized
mock_homewizardenergy_v2.device.side_effect = UnauthorizedError
mock_homewizardenergy_v2.get_token.side_effect = DisabledError
with (
patch(
"homeassistant.components.homewizard.config_flow.has_v2_api",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
# Simulate user authorizing
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
with (
patch(
"homeassistant.components.homewizard.config_flow.has_v2_api",
return_value=True,
),
):
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "usage"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"usage": "generation"},
)
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
@@ -838,16 +700,7 @@ async def test_manual_flow_detects_failed_user_authorization(
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Energy monitoring devices with an with configurable usage have an extra flow step
assert (
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")
if result["type"] is FlowResultType.FORM and result["step_id"] == "usage":
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"usage": "generation"}
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup_entry.mock_calls) == 1
@@ -977,12 +830,10 @@ async def test_discovery_with_v2_api_ask_authorization(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "authorize"
mock_homewizardenergy_v2.device.side_effect = None
mock_homewizardenergy_v2.get_token.side_effect = None
mock_homewizardenergy_v2.get_token.return_value = "cool_token"
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
# Energy monitoring devices with an with configurable usage have an extra flow step
assert (
result["type"] is FlowResultType.CREATE_ENTRY and result["data"][CONF_TOKEN]
) or (result["type"] is FlowResultType.FORM and result["step_id"] == "usage")
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_TOKEN] == "cool_token"

View File

@@ -1,6 +1,5 @@
"""Tests for the homewizard component."""
from collections.abc import Iterable
from datetime import timedelta
from unittest.mock import MagicMock, patch
import weakref
@@ -12,14 +11,8 @@ import pytest
from homeassistant.components.homewizard import get_main_device
from homeassistant.components.homewizard.const import DOMAIN
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import EntityRegistry
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from tests.common import MockConfigEntry, async_fire_time_changed
@@ -261,203 +254,3 @@ async def test_disablederror_reloads_integration(
flow = flows[0]
assert flow.get("step_id") == "reauth_enable_api"
assert flow.get("handler") == DOMAIN
@pytest.mark.usefixtures("mock_homewizardenergy")
@pytest.mark.parametrize(
("device_fixture", "mock_config_entry", "enabled", "disabled"),
[
(
"HWE-SKT-21-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
("sensor.device_power",),
("sensor.device_production_power",),
),
(
"HWE-SKT-21-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
(
"HWE-SKT-21",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# device has a non zero export, so both sensors are enabled
(
"sensor.device_power",
"sensor.device_production_power",
),
(),
),
(
"HWE-SKT-21",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-SKT_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
],
ids=[
"consumption_intital",
"generation_initial",
"consumption_used",
"generation_used",
],
)
async def test_setup_device_energy_monitoring_v1(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_config_entry: MockConfigEntry,
enabled: Iterable[str],
disabled: Iterable[str],
) -> None:
"""Test correct entities are enabled by default."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
for enabled_item in enabled:
assert (entry := entity_registry.async_get(enabled_item))
assert not entry.disabled
for disabled_item in disabled:
assert (entry := entity_registry.async_get(disabled_item))
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION
@pytest.mark.usefixtures("mock_homewizardenergy")
@pytest.mark.parametrize(
("device_fixture", "mock_config_entry", "enabled", "disabled"),
[
(
"HWE-KWH1-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
("sensor.device_power",),
("sensor.device_production_power",),
),
(
"HWE-KWH1-initial",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
(
"HWE-KWH1",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "consumption",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# device has a non zero export, so both sensors are enabled
(
"sensor.device_power",
"sensor.device_production_power",
),
(),
),
(
"HWE-KWH1",
MockConfigEntry(
title="Device",
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "127.0.0.1",
"usage": "generation",
},
unique_id="HWE-KWH1_5c2fafabcdef",
),
# we explicitly indicated that the device was monitoring
# generated energy, so we ignore power sensor to avoid confusion
("sensor.device_production_power",),
("sensor.device_power",),
),
],
ids=[
"consumption_intital",
"generation_initial",
"consumption_used",
"generation_used",
],
)
async def test_setup_device_energy_monitoring_v2(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_config_entry: MockConfigEntry,
enabled: Iterable[str],
disabled: Iterable[str],
) -> None:
"""Test correct entities are enabled by default."""
mock_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.homewizard.config_flow.has_v2_api", return_value=True
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done(wait_background_tasks=True)
for enabled_item in enabled:
assert (entry := entity_registry.async_get(enabled_item))
assert not entry.disabled
for disabled_item in disabled:
assert (entry := entity_registry.async_get(disabled_item))
assert entry.disabled
assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION

View File

@@ -132,7 +132,7 @@ async def test_turn_on_safety_exception(
mock_huum_client.turn_on.side_effect = SafetyException("Door is open")
mock_huum_client.status.return_value.status = SaunaStatus.ONLINE_HEATING
with pytest.raises(HomeAssistantError, match="Unable to turn on the sauna"):
with pytest.raises(HomeAssistantError, match="Unable to turn on sauna"):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,

View File

@@ -3,7 +3,7 @@
from typing import Any
from unittest.mock import patch
from lacrosse_view import HTTPError, Sensor
from lacrosse_view import Sensor
import pytest
from homeassistant.components.lacrosse_view.const import DOMAIN
@@ -230,54 +230,6 @@ async def test_no_readings(hass: HomeAssistant) -> None:
assert hass.states.get("sensor.test_temperature").state == "unavailable"
async def test_mixed_readings(hass: HomeAssistant) -> None:
"""Test a device without readings does not fail setup for the whole entry."""
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)
config_entry.add_to_hass(hass)
working_sensor = TEST_SENSOR.model_copy(
update={"name": "Working", "sensor_id": "working", "device_id": "working"}
)
no_readings_sensor = TEST_NO_READINGS_SENSOR.model_copy(
update={
"name": "No readings",
"sensor_id": "no_readings",
"device_id": "no_readings",
}
)
working_status = working_sensor.data
no_readings_status = no_readings_sensor.data
working_sensor.data = None
no_readings_sensor.data = None
with (
patch("lacrosse_view.LaCrosse.login", return_value=True),
patch(
"lacrosse_view.LaCrosse.get_devices",
return_value=[working_sensor, no_readings_sensor],
),
patch(
"lacrosse_view.LaCrosse.get_sensor_status",
side_effect=[
working_status,
HTTPError(
"Failed to get sensor status, status code: 404",
no_readings_status,
),
],
),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert entries
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
assert hass.states.get("sensor.working_temperature").state == "2"
assert hass.states.get("sensor.no_readings_temperature").state == "unavailable"
async def test_other_error(hass: HomeAssistant) -> None:
"""Test behavior when there is an error."""
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_ENTRY_DATA)

View File

@@ -33,12 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
issue_registry as ir,
template,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er, template
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.typing import ConfigType
@@ -2309,41 +2304,3 @@ async def test_multi_platform_discovery(
async def test_mqtt_integration_level_imports(attr: str) -> None:
"""Test mqtt integration level public published imports are available."""
assert hasattr(mqtt, attr)
@pytest.mark.usefixtures("mqtt_client_mock")
@pytest.mark.parametrize(
"hass_config", [{mqtt.DOMAIN: {"sensor": {"state_topic": "test-topic"}}}]
)
async def test_yaml_config_without_entry(
hass: HomeAssistant, hass_config: ConfigType, issue_registry: ir.IssueRegistry
) -> None:
"""Test a repair issue is created for YAML setup without an active config entry."""
await async_setup_component(hass, mqtt.DOMAIN, hass_config)
issue = issue_registry.async_get_issue(
mqtt.DOMAIN, "yaml_setup_without_active_setup"
)
assert issue is not None
assert (
issue.learn_more_url == "https://www.home-assistant.io/integrations/mqtt/"
"#configuration"
)
@pytest.mark.parametrize(
"hass_config", [{mqtt.DOMAIN: {"sensor": {"state_topic": "test-topic"}}}]
)
async def test_yaml_config_with_active_mqtt_config_entry(
hass: HomeAssistant,
hass_config: ConfigType,
mqtt_mock_entry: MqttMockHAClientGenerator,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test no repair issue is created for YAML setup with an active config entry."""
await mqtt_mock_entry()
issue = issue_registry.async_get_issue(
mqtt.DOMAIN, "yaml_setup_without_active_setup"
)
state = hass.states.get("sensor.mqtt_sensor")
assert state is not None
assert issue is None

View File

@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, issue_registry as ir, selector
from . import create_image_gen_call_item, create_message_item, create_reasoning_item
from . import create_image_gen_call_item, create_message_item
from tests.common import MockConfigEntry
@@ -247,15 +247,8 @@ async def test_generate_image(
# Mock the OpenAI response stream
mock_create_stream.return_value = [
(
*create_reasoning_item(
id="rs_A",
output_index=0,
reasoning_summary=[["The user asks me to generate an image"]],
),
*create_image_gen_call_item(id="ig_A", output_index=1),
*create_message_item(id="msg_A", text="", output_index=2),
)
create_image_gen_call_item(id="ig_A", output_index=0),
create_message_item(id="msg_A", text="", output_index=1),
]
with patch.object(

View File

@@ -16,12 +16,7 @@ from homeassistant.components.satel_integra.const import (
DEFAULT_PORT,
DOMAIN,
)
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigEntryState,
ConfigSubentry,
)
from homeassistant.config_entries import SOURCE_RECONFIGURE, SOURCE_USER, ConfigSubentry
from homeassistant.const import CONF_CODE, CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@@ -368,60 +363,6 @@ async def test_reconfigure_flow_success(
assert mock_setup_entry.call_count == 1
async def test_reconfigure_flow_config_unchanged_loaded(
hass: HomeAssistant,
mock_satel: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure skips connection testing if loaded config is unchanged."""
await setup_integration(hass, mock_config_entry)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert mock_config_entry.state is ConfigEntryState.LOADED
result = await hass.config_entries.flow.async_configure(
result["flow_id"], dict(mock_config_entry.data)
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == MOCK_CONFIG_DATA
assert mock_satel.connect.call_count == 0
await hass.async_block_till_done()
assert mock_setup_entry.call_count == 1
async def test_reconfigure_flow_config_unchanged_not_loaded(
hass: HomeAssistant,
mock_satel: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure validates unchanged config if the entry is not loaded."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
result = await hass.config_entries.flow.async_configure(
result["flow_id"], dict(mock_config_entry.data)
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data == MOCK_CONFIG_DATA
assert mock_satel.connect.call_count == 1
assert mock_setup_entry.call_count == 1
async def test_reconfigure_connection_failed(
hass: HomeAssistant,
mock_satel: AsyncMock,

View File

@@ -793,13 +793,10 @@ async def mock_sleepy_rpc_device():
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry and async_unload_entry."""
with (
patch(
"homeassistant.components.shelly.async_setup_entry", return_value=True
) as mock_setup_entry,
patch("homeassistant.components.shelly.async_unload_entry", return_value=True),
):
"""Override async_setup_entry."""
with patch(
"homeassistant.components.shelly.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry

View File

@@ -1,7 +1,7 @@
"""Tests for webhooks."""
from ipaddress import IPv4Network
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
from telegram.error import TimedOut
@@ -31,10 +31,6 @@ async def test_set_webhooks_failed(
patch(
"homeassistant.components.telegram_bot.webhooks.Bot.set_webhook",
) as mock_set_webhook,
patch(
"homeassistant.components.telegram_bot.webhooks.Application.start",
AsyncMock(),
) as mock_start,
):
mock_set_webhook.side_effect = [TimedOut("mock timeout"), False]
@@ -62,8 +58,6 @@ async def test_set_webhooks_failed(
assert mock_webhooks_config_entry.state is ConfigEntryState.SETUP_ERROR
await hass.async_block_till_done()
assert mock_start.call_count == 0
async def test_set_webhooks(
hass: HomeAssistant,
@@ -74,16 +68,11 @@ async def test_set_webhooks(
) -> None:
"""Test set webhooks success."""
mock_webhooks_config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.telegram_bot.webhooks.Application.start", AsyncMock()
) as mock_start:
await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id)
await hass.config_entries.async_setup(mock_webhooks_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_webhooks_config_entry.state is ConfigEntryState.LOADED
mock_start.assert_called_once()
async def test_webhooks_update_invalid_json(

View File

@@ -1223,7 +1223,7 @@ async def test_traffic_rules(
expected_enable_call = deepcopy(traffic_rule)
expected_enable_call["enabled"] = True
assert aioclient_mock.call_count == call_count + 1
assert aioclient_mock.call_count == call_count + 2
assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call
@@ -1277,7 +1277,7 @@ async def test_traffic_routes(
expected_enable_call = deepcopy(traffic_route)
expected_enable_call["enabled"] = True
assert aioclient_mock.call_count == call_count + 1
assert aioclient_mock.call_count == call_count + 2
assert aioclient_mock.mock_calls[call_count][2] == expected_enable_call

View File

@@ -216,148 +216,3 @@ async def test_reauth_flow_errors(
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful"
async def test_reconfigure_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test successful reconfiguration flow."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "10.0.0.1",
CONF_API_TOKEN: "new-api-token",
CONF_VERIFY_SSL: True,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == "10.0.0.1"
assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token"
assert mock_config_entry.data[CONF_VERIFY_SSL] is True
async def test_reconfigure_flow_same_host_new_token(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguration flow with same host and new API token."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: MOCK_HOST,
CONF_API_TOKEN: "new-api-token",
CONF_VERIFY_SSL: False,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
assert mock_config_entry.data[CONF_HOST] == MOCK_HOST
assert mock_config_entry.data[CONF_API_TOKEN] == "new-api-token"
async def test_reconfigure_flow_already_configured(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfiguration flow aborts when host already configured."""
mock_config_entry.add_to_hass(hass)
other_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_HOST: "10.0.0.1",
CONF_API_TOKEN: "other-token",
CONF_VERIFY_SSL: False,
},
)
other_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "10.0.0.1",
CONF_API_TOKEN: "new-api-token",
CONF_VERIFY_SSL: True,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "error"),
[
(ApiConnectionError("Connection failed"), "cannot_connect"),
(ApiAuthError(), "invalid_auth"),
(RuntimeError("boom"), "unknown"),
],
)
async def test_reconfigure_flow_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_client: MagicMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
error: str,
) -> None:
"""Test reconfiguration flow errors and recovery."""
mock_config_entry.add_to_hass(hass)
result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reconfigure"
mock_client.authenticate.side_effect = exception
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "10.0.0.1",
CONF_API_TOKEN: "new-api-token",
CONF_VERIFY_SSL: True,
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": error}
mock_client.authenticate.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_HOST: "10.0.0.1",
CONF_API_TOKEN: "new-api-token",
CONF_VERIFY_SSL: True,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"