mirror of
https://github.com/home-assistant/core.git
synced 2026-03-31 12:56:25 +02:00
Compare commits
1 Commits
homewizard
...
simplify_i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27fe91d74 |
@@ -3,54 +3,27 @@ name: Home Assistant Integration knowledge
|
||||
description: Everything you need to know to build, test and review Home Assistant Integrations. If you're looking at an integration, you must use this as your primary reference.
|
||||
---
|
||||
|
||||
### File Locations
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## Integration Templates
|
||||
## General guidelines
|
||||
|
||||
### Standard Integration Structure
|
||||
```
|
||||
homeassistant/components/my_integration/
|
||||
├── __init__.py # Entry point with async_setup_entry
|
||||
├── manifest.json # Integration metadata and dependencies
|
||||
├── const.py # Domain and constants
|
||||
├── config_flow.py # UI configuration flow
|
||||
├── coordinator.py # Data update coordinator (if needed)
|
||||
├── entity.py # Base entity class (if shared patterns)
|
||||
├── sensor.py # Sensor platform
|
||||
├── strings.json # User-facing text and translations
|
||||
├── services.yaml # Service definitions (if applicable)
|
||||
└── quality_scale.yaml # Quality scale rule status
|
||||
```
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
|
||||
|
||||
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). The following platforms have extra guidelines:
|
||||
The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
### Minimal Integration Checklist
|
||||
- [ ] `manifest.json` with required fields (domain, name, codeowners, etc.)
|
||||
- [ ] `__init__.py` with `async_setup_entry` and `async_unload_entry`
|
||||
- [ ] `config_flow.py` with UI configuration support
|
||||
- [ ] `const.py` with `DOMAIN` constant
|
||||
- [ ] `strings.json` with at least config flow text
|
||||
- [ ] Platform files (`sensor.py`, etc.) as needed
|
||||
- [ ] `quality_scale.yaml` with rule status tracking
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
Home Assistant uses an Integration Quality Scale to ensure code quality and consistency. The quality level determines which rules apply:
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
### Quality Scale Levels
|
||||
- **Bronze**: Basic requirements (ALL Bronze rules are mandatory)
|
||||
- **Silver**: Enhanced functionality
|
||||
- **Gold**: Advanced features
|
||||
- **Platinum**: Highest quality standards
|
||||
|
||||
### Quality Scale Progression
|
||||
- **Bronze → Silver**: Add entity unavailability, parallel updates, auth flows
|
||||
- **Silver → Gold**: Add device management, diagnostics, translations
|
||||
- **Gold → Platinum**: Add strict typing, async dependencies, websession injection
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
@@ -61,726 +34,7 @@ Home Assistant uses an Integration Quality Scale to ensure code quality and cons
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
### Example `quality_scale.yaml` Structure
|
||||
```yaml
|
||||
rules:
|
||||
# Bronze (mandatory)
|
||||
config-flow: done
|
||||
entity-unique-id: done
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
|
||||
# Silver (if targeting Silver+)
|
||||
entity-unavailable: done
|
||||
parallel-updates: done
|
||||
|
||||
# Gold (if targeting Gold+)
|
||||
devices: done
|
||||
diagnostics: done
|
||||
|
||||
# Platinum (if targeting Platinum)
|
||||
strict-typing: done
|
||||
```
|
||||
|
||||
**When Reviewing/Creating Code**: Always check the integration's quality scale level and exemption status before applying rules.
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Core Locations
|
||||
- Shared constants: `homeassistant/const.py` (use these instead of hardcoding)
|
||||
- Integration structure:
|
||||
- `homeassistant/components/{domain}/const.py` - Constants
|
||||
- `homeassistant/components/{domain}/models.py` - Data models
|
||||
- `homeassistant/components/{domain}/coordinator.py` - Update coordinator
|
||||
- `homeassistant/components/{domain}/config_flow.py` - Configuration flow
|
||||
- `homeassistant/components/{domain}/{platform}.py` - Platform implementations
|
||||
|
||||
### Common Modules
|
||||
- **coordinator.py**: Centralize data fetching logic
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator[MyData]):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
```
|
||||
- **entity.py**: Base entity definitions to reduce duplication
|
||||
```python
|
||||
class MyEntity(CoordinatorEntity[MyCoordinator]):
|
||||
_attr_has_entity_name = True
|
||||
```
|
||||
|
||||
### Runtime Data Storage
|
||||
- **Use ConfigEntry.runtime_data**: Store non-persistent runtime data
|
||||
```python
|
||||
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyIntegrationConfigEntry) -> bool:
|
||||
client = MyClient(entry.data[CONF_HOST])
|
||||
entry.runtime_data = client
|
||||
```
|
||||
|
||||
### Manifest Requirements
|
||||
- **Required Fields**: `domain`, `name`, `codeowners`, `integration_type`, `documentation`, `requirements`
|
||||
- **Integration Types**: `device`, `hub`, `service`, `system`, `helper`
|
||||
- **IoT Class**: Always specify connectivity method (e.g., `cloud_polling`, `local_polling`, `local_push`)
|
||||
- **Discovery Methods**: Add when applicable: `zeroconf`, `dhcp`, `bluetooth`, `ssdp`, `usb`
|
||||
- **Dependencies**: Include platform dependencies (e.g., `application_credentials`, `bluetooth_adapters`)
|
||||
|
||||
### Config Flow Patterns
|
||||
- **Version Control**: Always set `VERSION = 1` and `MINOR_VERSION = 1`
|
||||
- **Unique ID Management**:
|
||||
```python
|
||||
await self.async_set_unique_id(device_unique_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
```
|
||||
- **Error Handling**: Define errors in `strings.json` under `config.error`
|
||||
- **Step Methods**: Use standard naming (`async_step_user`, `async_step_discovery`, etc.)
|
||||
|
||||
### Integration Ownership
|
||||
- **manifest.json**: Add GitHub usernames to `codeowners`:
|
||||
```json
|
||||
{
|
||||
"domain": "my_integration",
|
||||
"name": "My Integration",
|
||||
"codeowners": ["@me"]
|
||||
}
|
||||
```
|
||||
|
||||
### Async Dependencies (Platinum)
|
||||
- **Requirement**: All dependencies must use asyncio
|
||||
- Ensures efficient task handling without thread context switching
|
||||
|
||||
### WebSession Injection (Platinum)
|
||||
- **Pass WebSession**: Support passing web sessions to dependencies
|
||||
```python
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Set up integration from config entry."""
|
||||
client = MyClient(entry.data[CONF_HOST], async_get_clientsession(hass))
|
||||
```
|
||||
- For cookies: Use `async_create_clientsession` (aiohttp) or `create_async_httpx_client` (httpx)
|
||||
|
||||
### Data Update Coordinator
|
||||
- **Standard Pattern**: Use for efficient data management
|
||||
```python
|
||||
class MyCoordinator(DataUpdateCoordinator):
|
||||
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
|
||||
super().__init__(
|
||||
hass,
|
||||
logger=LOGGER,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=5),
|
||||
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_update_data(self):
|
||||
try:
|
||||
return await self.client.fetch_data()
|
||||
except ApiError as err:
|
||||
raise UpdateFailed(f"API communication error: {err}")
|
||||
```
|
||||
- **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues
|
||||
- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended
|
||||
|
||||
## Integration Guidelines
|
||||
|
||||
### Configuration Flow
|
||||
- **UI Setup Required**: All integrations must support configuration via UI
|
||||
- **Manifest**: Set `"config_flow": true` in `manifest.json`
|
||||
- **Data Storage**:
|
||||
- Connection-critical config: Store in `ConfigEntry.data`
|
||||
- Non-critical settings: Store in `ConfigEntry.options`
|
||||
- **Validation**: Always validate user input before creating entries
|
||||
- **Config Entry Naming**:
|
||||
- ❌ Do NOT allow users to set config entry names in config flows
|
||||
- Names are automatically generated or can be customized later in UI
|
||||
- ✅ Exception: Helper integrations MAY allow custom names in config flow
|
||||
- **Connection Testing**: Test device/service connection during config flow:
|
||||
```python
|
||||
try:
|
||||
await client.get_data()
|
||||
except MyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
```
|
||||
- **Duplicate Prevention**: Prevent duplicate configurations:
|
||||
```python
|
||||
# Using unique ID
|
||||
await self.async_set_unique_id(identifier)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Using unique data
|
||||
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
|
||||
```
|
||||
|
||||
### Reauthentication Support
|
||||
- **Required Method**: Implement `async_step_reauth` in config flow
|
||||
- **Credential Updates**: Allow users to update credentials without re-adding
|
||||
- **Validation**: Verify account matches existing unique ID:
|
||||
```python
|
||||
await self.async_set_unique_id(user_id)
|
||||
self._abort_if_unique_id_mismatch(reason="wrong_account")
|
||||
return self.async_update_reload_and_abort(
|
||||
self._get_reauth_entry(),
|
||||
data_updates={CONF_API_TOKEN: user_input[CONF_API_TOKEN]}
|
||||
)
|
||||
```
|
||||
|
||||
### Reconfiguration Flow
|
||||
- **Purpose**: Allow configuration updates without removing device
|
||||
- **Implementation**: Add `async_step_reconfigure` method
|
||||
- **Validation**: Prevent changing underlying account with `_abort_if_unique_id_mismatch`
|
||||
|
||||
### Device Discovery
|
||||
- **Manifest Configuration**: Add discovery method (zeroconf, dhcp, etc.)
|
||||
```json
|
||||
{
|
||||
"zeroconf": ["_mydevice._tcp.local."]
|
||||
}
|
||||
```
|
||||
- **Discovery Handler**: Implement appropriate `async_step_*` method:
|
||||
```python
|
||||
async def async_step_zeroconf(self, discovery_info):
|
||||
"""Handle zeroconf discovery."""
|
||||
await self.async_set_unique_id(discovery_info.properties["serialno"])
|
||||
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
|
||||
```
|
||||
- **Network Updates**: Use discovery to update dynamic IP addresses
|
||||
|
||||
### Network Discovery Implementation
|
||||
- **Zeroconf/mDNS**: Use async instances
|
||||
```python
|
||||
aiozc = await zeroconf.async_get_async_instance(hass)
|
||||
```
|
||||
- **SSDP Discovery**: Register callbacks with cleanup
|
||||
```python
|
||||
entry.async_on_unload(
|
||||
ssdp.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"st": "urn:schemas-upnp-org:device:ZonePlayer:1"}
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Bluetooth Integration
|
||||
- **Manifest Dependencies**: Add `bluetooth_adapters` to dependencies
|
||||
- **Connectable**: Set `"connectable": true` for connection-required devices
|
||||
- **Scanner Usage**: Always use shared scanner instance
|
||||
```python
|
||||
scanner = bluetooth.async_get_scanner()
|
||||
entry.async_on_unload(
|
||||
bluetooth.async_register_callback(
|
||||
hass, _async_discovered_device,
|
||||
{"service_uuid": "example_uuid"},
|
||||
bluetooth.BluetoothScanningMode.ACTIVE
|
||||
)
|
||||
)
|
||||
```
|
||||
- **Connection Handling**: Never reuse `BleakClient` instances, use 10+ second timeouts
|
||||
|
||||
### Setup Validation
|
||||
- **Test Before Setup**: Verify integration can be set up in `async_setup_entry`
|
||||
- **Exception Handling**:
|
||||
- `ConfigEntryNotReady`: Device offline or temporary failure
|
||||
- `ConfigEntryAuthFailed`: Authentication issues
|
||||
- `ConfigEntryError`: Unresolvable setup problems
|
||||
|
||||
### Config Entry Unloading
|
||||
- **Required**: Implement `async_unload_entry` for runtime removal/reload
|
||||
- **Platform Unloading**: Use `hass.config_entries.async_unload_platforms`
|
||||
- **Cleanup**: Register callbacks with `entry.async_on_unload`:
|
||||
```python
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MyConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
entry.runtime_data.listener() # Clean up resources
|
||||
return unload_ok
|
||||
```
|
||||
|
||||
### Service Actions
|
||||
- **Registration**: Register all service actions in `async_setup`, NOT in `async_setup_entry`
|
||||
- **Validation**: Check config entry existence and loaded state:
|
||||
```python
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
async def service_action(call: ServiceCall) -> ServiceResponse:
|
||||
if not (entry := hass.config_entries.async_get_entry(call.data[ATTR_CONFIG_ENTRY_ID])):
|
||||
raise ServiceValidationError("Entry not found")
|
||||
if entry.state is not ConfigEntryState.LOADED:
|
||||
raise ServiceValidationError("Entry not loaded")
|
||||
```
|
||||
- **Exception Handling**: Raise appropriate exceptions:
|
||||
```python
|
||||
# For invalid input
|
||||
if end_date < start_date:
|
||||
raise ServiceValidationError("End date must be after start date")
|
||||
|
||||
# For service errors
|
||||
try:
|
||||
await client.set_schedule(start_date, end_date)
|
||||
except MyConnectionError as err:
|
||||
raise HomeAssistantError("Could not connect to the schedule") from err
|
||||
```
|
||||
|
||||
### Service Registration Patterns
|
||||
- **Entity Services**: Register on platform setup
|
||||
```python
|
||||
platform.async_register_entity_service(
|
||||
"my_entity_service",
|
||||
{vol.Required("parameter"): cv.string},
|
||||
"handle_service_method"
|
||||
)
|
||||
```
|
||||
- **Service Schema**: Always validate input
|
||||
```python
|
||||
SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required("entity_id"): cv.entity_ids,
|
||||
vol.Required("parameter"): cv.string,
|
||||
vol.Optional("timeout", default=30): cv.positive_int,
|
||||
})
|
||||
```
|
||||
- **Services File**: Create `services.yaml` with descriptions and field definitions
|
||||
|
||||
### Polling
|
||||
- Use update coordinator pattern when possible
|
||||
- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries
|
||||
- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input
|
||||
- **Minimum Intervals**:
|
||||
- Local network: 5 seconds
|
||||
- Cloud services: 60 seconds
|
||||
- **Parallel Updates**: Specify number of concurrent updates:
|
||||
```python
|
||||
PARALLEL_UPDATES = 1 # Serialize updates to prevent overwhelming device
|
||||
# OR
|
||||
PARALLEL_UPDATES = 0 # Unlimited (for coordinator-based or read-only)
|
||||
```
|
||||
|
||||
## Entity Development
|
||||
|
||||
### Unique IDs
|
||||
- **Required**: Every entity must have a unique ID for registry tracking
|
||||
- Must be unique per platform (not per integration)
|
||||
- Don't include integration domain or platform in ID
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
def __init__(self, device_id: str) -> None:
|
||||
self._attr_unique_id = f"{device_id}_temperature"
|
||||
```
|
||||
|
||||
**Acceptable ID Sources**:
|
||||
- Device serial numbers
|
||||
- MAC addresses (formatted using `format_mac` from device registry)
|
||||
- Physical identifiers (printed/EEPROM)
|
||||
- Config entry ID as last resort: `f"{entry.entry_id}-battery"`
|
||||
|
||||
**Never Use**:
|
||||
- IP addresses, hostnames, URLs
|
||||
- Device names
|
||||
- Email addresses, usernames
|
||||
|
||||
### Entity Descriptions
|
||||
- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation
|
||||
- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability
|
||||
- **Bad pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long
|
||||
)
|
||||
```
|
||||
- **Good pattern**:
|
||||
```python
|
||||
SensorEntityDescription(
|
||||
key="temperature",
|
||||
name="Temperature",
|
||||
value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda
|
||||
round(data["temp_value"] * 1.8 + 32, 1)
|
||||
if data.get("temp_value") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Entity Naming
|
||||
- **Use has_entity_name**: Set `_attr_has_entity_name = True`
|
||||
- **For specific fields**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
def __init__(self, device: Device, field: str) -> None:
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
)
|
||||
self._attr_name = field # e.g., "temperature", "humidity"
|
||||
```
|
||||
- **For device itself**: Set `_attr_name = None`
|
||||
|
||||
### Event Lifecycle Management
|
||||
- **Subscribe in `async_added_to_hass`**:
|
||||
```python
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to events."""
|
||||
self.async_on_remove(
|
||||
self.client.events.subscribe("my_event", self._handle_event)
|
||||
)
|
||||
```
|
||||
- **Unsubscribe in `async_will_remove_from_hass`** if not using `async_on_remove`
|
||||
- Never subscribe in `__init__` or other methods
|
||||
|
||||
### State Handling
|
||||
- Unknown values: Use `None` (not "unknown" or "unavailable")
|
||||
- Availability: Implement `available()` property instead of using "unavailable" state
|
||||
|
||||
### Entity Availability
|
||||
- **Mark Unavailable**: When data cannot be fetched from device/service
|
||||
- **Coordinator Pattern**:
|
||||
```python
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
return super().available and self.identifier in self.coordinator.data
|
||||
```
|
||||
- **Direct Update Pattern**:
|
||||
```python
|
||||
async def async_update(self) -> None:
|
||||
"""Update entity."""
|
||||
try:
|
||||
data = await self.client.get_data()
|
||||
except MyException:
|
||||
self._attr_available = False
|
||||
else:
|
||||
self._attr_available = True
|
||||
self._attr_native_value = data.value
|
||||
```
|
||||
|
||||
### Extra State Attributes
|
||||
- All attribute keys must always be present
|
||||
- Unknown values: Use `None`
|
||||
- Provide descriptive attributes
|
||||
|
||||
## Device Management
|
||||
|
||||
### Device Registry
|
||||
- **Create Devices**: Group related entities under devices
|
||||
- **Device Info**: Provide comprehensive metadata:
|
||||
```python
|
||||
_attr_device_info = DeviceInfo(
|
||||
connections={(CONNECTION_NETWORK_MAC, device.mac)},
|
||||
identifiers={(DOMAIN, device.id)},
|
||||
name=device.name,
|
||||
manufacturer="My Company",
|
||||
model="My Sensor",
|
||||
sw_version=device.version,
|
||||
)
|
||||
```
|
||||
- For services: Add `entry_type=DeviceEntryType.SERVICE`
|
||||
|
||||
### Dynamic Device Addition
|
||||
- **Auto-detect New Devices**: After initial setup
|
||||
- **Implementation Pattern**:
|
||||
```python
|
||||
def _check_device() -> None:
|
||||
current_devices = set(coordinator.data)
|
||||
new_devices = current_devices - known_devices
|
||||
if new_devices:
|
||||
known_devices.update(new_devices)
|
||||
async_add_entities([MySensor(coordinator, device_id) for device_id in new_devices])
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_check_device))
|
||||
```
|
||||
|
||||
### Stale Device Removal
|
||||
- **Auto-remove**: When devices disappear from hub/account
|
||||
- **Device Registry Update**:
|
||||
```python
|
||||
device_registry.async_update_device(
|
||||
device_id=device.id,
|
||||
remove_config_entry_id=self.config_entry.entry_id,
|
||||
)
|
||||
```
|
||||
- **Manual Deletion**: Implement `async_remove_config_entry_device` when needed
|
||||
|
||||
### Entity Categories
|
||||
- **Required**: Assign appropriate category to entities
|
||||
- **Implementation**: Set `_attr_entity_category`
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
```
|
||||
- Categories include: `DIAGNOSTIC` for system/technical information
|
||||
|
||||
### Device Classes
|
||||
- **Use When Available**: Set appropriate device class for entity type
|
||||
```python
|
||||
class MyTemperatureSensor(SensorEntity):
|
||||
_attr_device_class = SensorDeviceClass.TEMPERATURE
|
||||
```
|
||||
- Provides context for: unit conversion, voice control, UI representation
|
||||
|
||||
### Disabled by Default
|
||||
- **Disable Noisy/Less Popular Entities**: Reduce resource usage
|
||||
```python
|
||||
class MySignalStrengthSensor(SensorEntity):
|
||||
_attr_entity_registry_enabled_default = False
|
||||
```
|
||||
- Target: frequently changing states, technical diagnostics
|
||||
|
||||
### Entity Translations
|
||||
- **Required with has_entity_name**: Support international users
|
||||
- **Implementation**:
|
||||
```python
|
||||
class MySensor(SensorEntity):
|
||||
_attr_has_entity_name = True
|
||||
_attr_translation_key = "phase_voltage"
|
||||
```
|
||||
- Create `strings.json` with translations:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"phase_voltage": {
|
||||
"name": "Phase voltage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Exception Translations (Gold)
|
||||
- **Translatable Errors**: Use translation keys for user-facing exceptions
|
||||
- **Implementation**:
|
||||
```python
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_date_before_start_date",
|
||||
)
|
||||
```
|
||||
- Add to `strings.json`:
|
||||
```json
|
||||
{
|
||||
"exceptions": {
|
||||
"end_date_before_start_date": {
|
||||
"message": "The end date cannot be before the start date."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Icon Translations (Gold)
|
||||
- **Dynamic Icons**: Support state and range-based icon selection
|
||||
- **State-based Icons**:
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"tree_pollen": {
|
||||
"default": "mdi:tree",
|
||||
"state": {
|
||||
"high": "mdi:tree-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **Range-based Icons** (for numeric values):
|
||||
```json
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"battery_level": {
|
||||
"default": "mdi:battery-unknown",
|
||||
"range": {
|
||||
"0": "mdi:battery-outline",
|
||||
"90": "mdi:battery-90",
|
||||
"100": "mdi:battery"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- **Location**: `tests/components/{domain}/`
|
||||
- **Coverage Requirement**: Above 95% test coverage for all modules
|
||||
- **Best Practices**:
|
||||
- Use pytest fixtures from `tests.common`
|
||||
- Mock all external dependencies
|
||||
- Use snapshots for complex data structures
|
||||
- Follow existing test patterns
|
||||
|
||||
### Config Flow Testing
|
||||
- **100% Coverage Required**: All config flow paths must be tested
|
||||
- **Patch Boundaries**: Only patch library or client methods when testing config flows. Do not patch methods defined in `config_flow.py`; exercise the flow logic end-to-end.
|
||||
- **Test Scenarios**:
|
||||
- All flow initiation methods (user, discovery, import)
|
||||
- Successful configuration paths
|
||||
- Error recovery scenarios
|
||||
- Prevention of duplicate entries
|
||||
- Flow completion after errors
|
||||
- Reauthentication/reconfigure flows
|
||||
|
||||
### Testing
|
||||
- **Integration-specific tests** (recommended):
|
||||
```bash
|
||||
pytest ./tests/components/<integration_domain> \
|
||||
--cov=homeassistant.components.<integration_domain> \
|
||||
--cov-report term-missing \
|
||||
--durations-min=1 \
|
||||
--durations=0 \
|
||||
--numprocesses=auto
|
||||
```
|
||||
|
||||
### Testing Best Practices
|
||||
- **Never access `hass.data` directly** - Use fixtures and proper integration setup instead
|
||||
- **Use snapshot testing** - For verifying entity states and attributes
|
||||
- **Test through integration setup** - Don't test entities in isolation
|
||||
- **Mock external APIs** - Use fixtures with realistic JSON data
|
||||
- **Verify registries** - Ensure entities are properly registered with devices
|
||||
|
||||
### Config Flow Testing Template
|
||||
```python
|
||||
async def test_user_flow_success(hass, mock_api):
|
||||
"""Test successful user flow."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
|
||||
# Test form submission
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "My Device"
|
||||
assert result["data"] == TEST_USER_INPUT
|
||||
|
||||
async def test_flow_connection_error(hass, mock_api_error):
|
||||
"""Test connection error handling."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"], user_input=TEST_USER_INPUT
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
```
|
||||
|
||||
### Entity Testing Patterns
|
||||
```python
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Overridden fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR] # Or another specific platform as needed.
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_entities(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test the sensor entities."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
# Ensure entities are correctly assigned to device
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "device_unique_id")}
|
||||
)
|
||||
assert device_entry
|
||||
entity_entries = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
for entity_entry in entity_entries:
|
||||
assert entity_entry.device_id == device_entry.id
|
||||
```
|
||||
|
||||
### Mock Patterns
|
||||
```python
|
||||
# Modern integration fixture setup
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Return the default mocked config entry."""
|
||||
return MockConfigEntry(
|
||||
title="My Integration",
|
||||
domain=DOMAIN,
|
||||
data={CONF_HOST: "127.0.0.1", CONF_API_KEY: "test_key"},
|
||||
unique_id="device_unique_id",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device_api() -> Generator[MagicMock]:
|
||||
"""Return a mocked device API."""
|
||||
with patch("homeassistant.components.my_integration.MyDeviceAPI", autospec=True) as api_mock:
|
||||
api = api_mock.return_value
|
||||
api.get_data.return_value = MyDeviceData.from_json(
|
||||
load_fixture("device_data.json", DOMAIN)
|
||||
)
|
||||
yield api
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return PLATFORMS
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device_api: MagicMock,
|
||||
platforms: list[Platform],
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.my_integration.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return mock_config_entry
|
||||
```
|
||||
|
||||
## Debugging & Troubleshooting
|
||||
|
||||
### Common Issues & Solutions
|
||||
- **Integration won't load**: Check `manifest.json` syntax and required fields
|
||||
- **Entities not appearing**: Verify `unique_id` and `has_entity_name` implementation
|
||||
- **Config flow errors**: Check `strings.json` entries and error handling
|
||||
- **Discovery not working**: Verify manifest discovery configuration and callbacks
|
||||
- **Tests failing**: Check mock setup and async context
|
||||
|
||||
### Debug Logging Setup
|
||||
```python
|
||||
# Enable debug logging in tests
|
||||
caplog.set_level(logging.DEBUG, logger="my_integration")
|
||||
|
||||
# In integration code - use proper logging
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_LOGGER.debug("Processing data: %s", data) # Use lazy logging
|
||||
```
|
||||
|
||||
### Validation Commands
|
||||
```bash
|
||||
# Check specific integration
|
||||
python -m script.hassfest --integration-path homeassistant/components/my_integration
|
||||
|
||||
# Validate quality scale
|
||||
# Check quality_scale.yaml against current rules
|
||||
|
||||
# Run integration tests with coverage
|
||||
pytest ./tests/components/my_integration \
|
||||
--cov=homeassistant.components.my_integration \
|
||||
--cov-report term-missing
|
||||
```
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
|
||||
@@ -3,17 +3,4 @@
|
||||
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
|
||||
|
||||
- **Required**: Implement diagnostic data collection
|
||||
- **Implementation**:
|
||||
```python
|
||||
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: MyConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return {
|
||||
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||
"data": entry.runtime_data.data,
|
||||
}
|
||||
```
|
||||
- **Security**: Never expose passwords, tokens, or sensitive coordinates
|
||||
|
||||
@@ -8,48 +8,10 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
|
||||
- Provide specific steps users need to take to resolve the issue
|
||||
- Use friendly, helpful language
|
||||
- Include relevant context (device names, error details, etc.)
|
||||
- **Implementation**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"outdated_version",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="outdated_version",
|
||||
)
|
||||
```
|
||||
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
|
||||
```json
|
||||
{
|
||||
"issues": {
|
||||
"outdated_version": {
|
||||
"title": "Device firmware is outdated",
|
||||
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- **String Content Must Include**:
|
||||
- What the problem is
|
||||
- Why it matters
|
||||
- Exact steps to resolve (numbered list when multiple steps)
|
||||
- What to expect after following the steps
|
||||
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
|
||||
- **Severity Guidelines**:
|
||||
- `CRITICAL`: Reserved for extreme scenarios only
|
||||
- `ERROR`: Requires immediate user attention
|
||||
- `WARNING`: Indicates future potential breakage
|
||||
- **Additional Attributes**:
|
||||
```python
|
||||
ir.async_create_issue(
|
||||
hass, DOMAIN, "issue_id",
|
||||
breaks_in_ha_version="2024.1.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="issue_description",
|
||||
)
|
||||
```
|
||||
- Only create issues for problems users can potentially resolve
|
||||
|
||||
@@ -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
4
CODEOWNERS
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -124,7 +124,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"battery",
|
||||
"calendar",
|
||||
"climate",
|
||||
"counter",
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pycasperglow"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pycasperglow==1.2.0"]
|
||||
"requirements": ["pycasperglow==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_value": {
|
||||
"condition": "mdi:counter"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"decrement": {
|
||||
"service": "mdi:numeric-negative-1"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -68,5 +68,5 @@ rules:
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
inject-websession: done
|
||||
inject-websession: todo
|
||||
strict-typing: todo
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -140,7 +140,6 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
"force_update",
|
||||
"group_entities",
|
||||
"icon",
|
||||
"friendly_name",
|
||||
"should_poll",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 {}),
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
{
|
||||
"entity": {
|
||||
"button": {
|
||||
"shutdown": {
|
||||
"default": "mdi:power"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cpu_15min_load": {
|
||||
"default": "mdi:chip"
|
||||
|
||||
@@ -76,11 +76,6 @@
|
||||
"name": "Security status"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"shutdown": {
|
||||
"name": "Shutdown"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"cpu_15min_load": {
|
||||
"name": "CPU load average (15 min)"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""The Thomson integration."""
|
||||
"""The thomson component."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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%]",
|
||||
|
||||
@@ -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
10
mypy.ini
generated
@@ -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
4
requirements_all.txt
generated
@@ -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
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"product_type": "HWE-KWH1",
|
||||
"product_name": "kWh meter",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "5.0103",
|
||||
"api_version": "v2"
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"product_type": "HWE-SKT",
|
||||
"product_name": "Energy Socket",
|
||||
"serial": "5c2fafabcdef",
|
||||
"firmware_version": "4.07",
|
||||
"api_version": "v1"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"power_on": true,
|
||||
"switch_lock": false,
|
||||
"brightness": 255
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"cloud_enabled": true
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user