Compare commits

..

2 Commits

Author SHA1 Message Date
abmantis
4748e925e0 Address review 2026-03-31 20:06:51 +01:00
abmantis
c27fe91d74 Simplify claude's integrations skill 2026-03-30 19:12:29 +01:00
397 changed files with 3861 additions and 8066 deletions

View File

@@ -5,6 +5,14 @@ description: Review a GitHub pull request and provide feedback comments. Use whe
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.

View File

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

View File

@@ -3,17 +3,4 @@
Platform exists as `homeassistant/components/<domain>/diagnostics.py`.
- **Required**: Implement diagnostic data collection
- **Implementation**:
```python
TO_REDACT = [CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MyConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": entry.runtime_data.data,
}
```
- **Security**: Never expose passwords, tokens, or sensitive coordinates

View File

@@ -8,29 +8,6 @@ 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
@@ -41,15 +18,4 @@ Platform exists as `homeassistant/components/<domain>/repairs.py`.
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve

View File

@@ -280,7 +280,7 @@ jobs:
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
echo "::add-matcher::.github/workflows/matchers/codespell.json"
- name: Run prek
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
env:
PREK_SKIP: no-commit-to-branch,mypy,pylint,gen_requirements_all,hassfest,hassfest-metadata,hassfest-mypy-config,zizmor
RUFF_OUTPUT_FORMAT: github
@@ -301,7 +301,7 @@ jobs:
with:
persist-credentials: false
- name: Run zizmor
uses: j178/prek-action@53276d8b0d10f8b6672aa85b4588c6921d0370cc # v2.0.1
uses: j178/prek-action@79f765515bd648eb4d6bb1b17277b7cb22cb6468 # v2.0.0
with:
extra-args: --all-files zizmor

View File

@@ -174,6 +174,7 @@ homeassistant.components.dnsip.*
homeassistant.components.doorbird.*
homeassistant.components.dormakaba_dkey.*
homeassistant.components.downloader.*
homeassistant.components.dropbox.*
homeassistant.components.droplet.*
homeassistant.components.dsmr.*
homeassistant.components.duckdns.*
@@ -578,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.*

14
CODEOWNERS generated
View File

@@ -222,8 +222,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/binary_sensor/ @home-assistant/core
/tests/components/binary_sensor/ @home-assistant/core
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/tests/components/blebox/ @bbx-a @swistakm @bkobus-bbx
/homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot
/tests/components/blink/ @fronzbot
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
@@ -401,6 +401,8 @@ build.json @home-assistant/supervisor
/tests/components/dremel_3d_printer/ @tkdrob
/homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer
/tests/components/drop_connect/ @ChandlerSystems @pfrazer
/homeassistant/components/dropbox/ @bdr99
/tests/components/dropbox/ @bdr99
/homeassistant/components/droplet/ @sarahseidman
/tests/components/droplet/ @sarahseidman
/homeassistant/components/dsmr/ @Robbie1221
@@ -739,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
@@ -1230,8 +1232,8 @@ build.json @home-assistant/supervisor
/tests/components/onvif/ @jterrace
/homeassistant/components/open_meteo/ @frenck
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/open_router/ @joostlek
/tests/components/open_router/ @joostlek
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq

View File

@@ -238,9 +238,7 @@ DEFAULT_INTEGRATIONS = {
"timer",
#
# Base platforms:
# Note: Calendar and todo are not included to prevent them from registering
# their frontend panels when there are no calendar or todo integrations.
*(BASE_PLATFORMS - {"calendar", "todo"}),
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"air_quality",

View File

@@ -1 +1 @@
"""The Actiontec integration."""
"""The actiontec component."""

View File

@@ -1,18 +1,25 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
"condition_behavior_description": "How the value should match on the targeted entities.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_co2_value": {
"description": "Tests the carbon dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -22,6 +29,7 @@
"description": "Tests if one or more carbon monoxide sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -31,6 +39,7 @@
"description": "Tests if one or more carbon monoxide sensors are detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -40,9 +49,11 @@
"description": "Tests the carbon monoxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -52,6 +63,7 @@
"description": "Tests if one or more gas sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -61,6 +73,7 @@
"description": "Tests if one or more gas sensors are detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -70,9 +83,11 @@
"description": "Tests the nitrous oxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -82,9 +97,11 @@
"description": "Tests the nitrogen dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -94,9 +111,11 @@
"description": "Tests the nitrogen monoxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -106,9 +125,11 @@
"description": "Tests the ozone level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -118,9 +139,11 @@
"description": "Tests the PM10 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -130,9 +153,11 @@
"description": "Tests the PM1 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -142,9 +167,11 @@
"description": "Tests the PM2.5 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -154,9 +181,11 @@
"description": "Tests the PM4 level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -166,6 +195,7 @@
"description": "Tests if one or more smoke sensors are cleared.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -175,6 +205,7 @@
"description": "Tests if one or more smoke sensors are detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
}
},
@@ -184,9 +215,11 @@
"description": "Tests the sulphur dioxide level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -196,9 +229,11 @@
"description": "Tests the volatile organic compounds ratio of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -208,9 +243,11 @@
"description": "Tests the volatile organic compounds level of one or more entities.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::condition_behavior_description%]",
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::condition_threshold_description%]",
"name": "[%key:component::air_quality::common::condition_threshold_name%]"
}
},
@@ -238,6 +275,7 @@
"description": "Triggers after one or more carbon dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -247,9 +285,11 @@
"description": "Triggers after one or more carbon dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -259,6 +299,7 @@
"description": "Triggers after one or more carbon monoxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -268,6 +309,7 @@
"description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -277,9 +319,11 @@
"description": "Triggers after one or more carbon monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -289,6 +333,7 @@
"description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -298,6 +343,7 @@
"description": "Triggers after one or more gas sensors stop detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -307,6 +353,7 @@
"description": "Triggers after one or more gas sensors start detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -316,6 +363,7 @@
"description": "Triggers after one or more nitrous oxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -325,9 +373,11 @@
"description": "Triggers after one or more nitrous oxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -337,6 +387,7 @@
"description": "Triggers after one or more nitrogen dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -346,9 +397,11 @@
"description": "Triggers after one or more nitrogen dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -358,6 +411,7 @@
"description": "Triggers after one or more nitrogen monoxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -367,9 +421,11 @@
"description": "Triggers after one or more nitrogen monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -379,6 +435,7 @@
"description": "Triggers after one or more ozone levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -388,9 +445,11 @@
"description": "Triggers after one or more ozone levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -400,6 +459,7 @@
"description": "Triggers after one or more PM10 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -409,9 +469,11 @@
"description": "Triggers after one or more PM10 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -421,6 +483,7 @@
"description": "Triggers after one or more PM1 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -430,9 +493,11 @@
"description": "Triggers after one or more PM1 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -442,6 +507,7 @@
"description": "Triggers after one or more PM2.5 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -451,9 +517,11 @@
"description": "Triggers after one or more PM2.5 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -463,6 +531,7 @@
"description": "Triggers after one or more PM4 levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -472,9 +541,11 @@
"description": "Triggers after one or more PM4 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -484,6 +555,7 @@
"description": "Triggers after one or more smoke sensors stop detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -493,6 +565,7 @@
"description": "Triggers after one or more smoke sensors start detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
@@ -502,6 +575,7 @@
"description": "Triggers after one or more sulphur dioxide levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -511,9 +585,11 @@
"description": "Triggers after one or more sulphur dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -523,6 +599,7 @@
"description": "Triggers after one or more volatile organic compound levels change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -532,9 +609,11 @@
"description": "Triggers after one or more volatile organic compounds levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -544,6 +623,7 @@
"description": "Triggers after one or more volatile organic compound ratios change.",
"fields": {
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_changed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},
@@ -553,9 +633,11 @@
"description": "Triggers after one or more volatile organic compounds ratios cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::air_quality::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_name%]"
}
},

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted alarms.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_armed": {
"description": "Tests if one or more alarms are armed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more alarms are armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -26,6 +30,7 @@
"description": "Tests if one or more alarms are armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -35,6 +40,7 @@
"description": "Tests if one or more alarms are armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -44,6 +50,7 @@
"description": "Tests if one or more alarms are armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -53,6 +60,7 @@
"description": "Tests if one or more alarms are disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -62,6 +70,7 @@
"description": "Tests if one or more alarms are triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
}
},
@@ -233,6 +242,7 @@
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -242,6 +252,7 @@
"description": "Triggers after one or more alarms become armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -251,6 +262,7 @@
"description": "Triggers after one or more alarms become armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -260,6 +272,7 @@
"description": "Triggers after one or more alarms become armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -269,6 +282,7 @@
"description": "Triggers after one or more alarms become armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -278,6 +292,7 @@
"description": "Triggers after one or more alarms become disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
@@ -287,6 +302,7 @@
"description": "Triggers after one or more alarms become triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
import logging
from typing import Any, cast
from typing import Any
from adext import AdExt
from alarmdecoder.devices import Device, SerialDevice, SocketDevice
from alarmdecoder.devices import SerialDevice, SocketDevice
from alarmdecoder.util import NoDeviceError
import voluptuous as vol
@@ -102,21 +102,16 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
self._async_current_entries(), user_input, self.protocol
):
return self.async_abort(reason="already_configured")
connection: dict[str, Any] = {}
connection = {}
baud = None
device: Device
if self.protocol == PROTOCOL_SOCKET:
host = connection[CONF_HOST] = cast(str, user_input[CONF_HOST])
port = connection[CONF_PORT] = cast(int, user_input[CONF_PORT])
title: str = f"{host}:{port}"
host = connection[CONF_HOST] = user_input[CONF_HOST]
port = connection[CONF_PORT] = user_input[CONF_PORT]
title = f"{host}:{port}"
device = SocketDevice(interface=(host, port))
if self.protocol == PROTOCOL_SERIAL:
path = connection[CONF_DEVICE_PATH] = cast(
str, user_input[CONF_DEVICE_PATH]
)
baud = connection[CONF_DEVICE_BAUD] = cast(
int, user_input[CONF_DEVICE_BAUD]
)
path = connection[CONF_DEVICE_PATH] = user_input[CONF_DEVICE_PATH]
baud = connection[CONF_DEVICE_BAUD] = user_input[CONF_DEVICE_BAUD]
title = path
device = SerialDevice(interface=path)
@@ -137,7 +132,6 @@ class AlarmDecoderFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception during AlarmDecoder setup")
errors["base"] = "unknown"
schema: vol.Schema
if self.protocol == PROTOCOL_SOCKET:
schema = vol.Schema(
{

View File

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

View File

@@ -71,16 +71,6 @@ CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS = [
"claude-haiku-4-5",
"claude-opus-4-1",
"claude-opus-4-0",
"claude-opus-4-20250514",
"claude-sonnet-4-0",
"claude-sonnet-4-20250514",
"claude-3-haiku",
]
DEPRECATED_MODELS = [
"claude-3",
]

View File

@@ -1,64 +0,0 @@
"""Diagnostics support for Anthropic."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from anthropic import __title__, __version__
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY
from homeassistant.helpers import entity_registry as er
from .const import (
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
)
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from . import AnthropicConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_PROMPT,
CONF_WEB_SEARCH_CITY,
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_COUNTRY,
CONF_WEB_SEARCH_TIMEZONE,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AnthropicConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return {
"client": f"{__title__}=={__version__}",
"title": entry.title,
"entry_id": entry.entry_id,
"entry_version": f"{entry.version}.{entry.minor_version}",
"state": entry.state.value,
"data": async_redact_data(entry.data, TO_REDACT),
"options": async_redact_data(entry.options, TO_REDACT),
"subentries": {
subentry.subentry_id: {
"title": subentry.title,
"subentry_type": subentry.subentry_type,
"data": async_redact_data(subentry.data, TO_REDACT),
}
for subentry in entry.subentries.values()
},
"entities": {
entity_entry.entity_id: entity_entry.extended_dict
for entity_entry in er.async_entries_for_config_entry(
er.async_get(hass), entry.entry_id
)
},
}

View File

@@ -19,8 +19,6 @@ from anthropic.types import (
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
CodeExecutionTool20250825Param,
CodeExecutionToolResultBlock,
CodeExecutionToolResultBlockParamContentParam,
Container,
ContentBlockParam,
DocumentBlockParam,
@@ -63,16 +61,15 @@ from anthropic.types import (
ToolUseBlockParam,
Usage,
WebSearchTool20250305Param,
WebSearchTool20260209Param,
WebSearchToolResultBlock,
WebSearchToolResultBlockParamContentParam,
)
from anthropic.types.bash_code_execution_tool_result_block_param import (
Content as BashCodeExecutionToolResultBlockParamContentParam,
Content as BashCodeExecutionToolResultContentParam,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
Content as TextEditorCodeExecutionToolResultBlockParamContentParam,
Content as TextEditorCodeExecutionToolResultContentParam,
)
import voluptuous as vol
from voluptuous_openapi import convert
@@ -108,7 +105,6 @@ from .const import (
MIN_THINKING_BUDGET,
NON_ADAPTIVE_THINKING_MODELS,
NON_THINKING_MODELS,
PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS,
UNSUPPORTED_STRUCTURED_OUTPUT_MODELS,
)
@@ -228,22 +224,12 @@ def _convert_content(
},
),
}
elif content.tool_name == "code_execution":
tool_result_block = {
"type": "code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
CodeExecutionToolResultBlockParamContentParam,
content.tool_result,
),
}
elif content.tool_name == "bash_code_execution":
tool_result_block = {
"type": "bash_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
BashCodeExecutionToolResultBlockParamContentParam,
content.tool_result,
BashCodeExecutionToolResultContentParam, content.tool_result
),
}
elif content.tool_name == "text_editor_code_execution":
@@ -251,7 +237,7 @@ def _convert_content(
"type": "text_editor_code_execution_tool_result",
"tool_use_id": content.tool_call_id,
"content": cast(
TextEditorCodeExecutionToolResultBlockParamContentParam,
TextEditorCodeExecutionToolResultContentParam,
content.tool_result,
),
}
@@ -382,7 +368,6 @@ def _convert_content(
name=cast(
Literal[
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
],
@@ -394,7 +379,6 @@ def _convert_content(
and tool_call.tool_name
in [
"web_search",
"code_execution",
"bash_code_execution",
"text_editor_code_execution",
]
@@ -486,7 +470,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
if response.content_block.name == output_tool:
@@ -548,14 +532,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
type="server_tool_use",
id=response.content_block.id,
name=response.content_block.name,
input=response.content_block.input or {},
input={},
)
current_tool_args = ""
elif isinstance(
response.content_block,
(
WebSearchToolResultBlock,
CodeExecutionToolResultBlock,
BashCodeExecutionToolResultBlock,
TextEditorCodeExecutionToolResultBlock,
),
@@ -611,13 +594,13 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] |= tool_args
current_tool_block["input"] = tool_args
yield {
"tool_calls": [
llm.ToolInput(
id=current_tool_block["id"],
tool_name=current_tool_block["name"],
tool_args=current_tool_block["input"],
tool_args=tool_args,
external=current_tool_block["type"] == "server_tool_use",
)
]
@@ -752,34 +735,19 @@ class AnthropicBaseLLMEntity(Entity):
]
if options.get(CONF_CODE_EXECUTION):
# The `web_search_20260209` tool automatically enables `code_execution_20260120` tool
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_WEB_SEARCH):
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
tools.append(
CodeExecutionTool20250825Param(
name="code_execution",
type="code_execution_20250825",
),
)
if options.get(CONF_WEB_SEARCH):
if model.startswith(
tuple(PROGRAMMATIC_TOOL_CALLING_UNSUPPORTED_MODELS)
) or not options.get(CONF_CODE_EXECUTION):
web_search: WebSearchTool20250305Param | WebSearchTool20260209Param = (
WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
)
else:
web_search = WebSearchTool20260209Param(
name="web_search",
type="web_search_20260209",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
web_search = WebSearchTool20250305Param(
name="web_search",
type="web_search_20250305",
max_uses=options.get(CONF_WEB_SEARCH_MAX_USES),
)
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
web_search["user_location"] = {
"type": "approximate",

View File

@@ -46,7 +46,7 @@ rules:
test-coverage: done
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
@@ -61,9 +61,12 @@ rules:
No data updates.
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-devices:
status: todo
comment: |
To write something about what models we support.
docs-supported-functions: done
docs-troubleshooting: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt

View File

@@ -1 +1 @@
"""The Arris TG2492LG integration."""
"""The Arris TG2492LG component."""

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_idle": {
"description": "Tests if one or more Assist satellites are idle.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more Assist satellites are listening.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -26,6 +30,7 @@
"description": "Tests if one or more Assist satellites are processing.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -35,6 +40,7 @@
"description": "Tests if one or more Assist satellites are responding.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
}
},
@@ -159,6 +165,7 @@
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -168,6 +175,7 @@
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -177,6 +185,7 @@
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
@@ -186,6 +195,7 @@
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},

View File

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

View File

@@ -1,15 +1,21 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
"condition_behavior_description": "How the state should match on the targeted batteries.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted batteries to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_charging": {
"description": "Tests if one or more batteries are charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
@@ -19,9 +25,11 @@
"description": "Tests the battery level of one or more batteries.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::battery::common::condition_threshold_description%]",
"name": "[%key:component::battery::common::condition_threshold_name%]"
}
},
@@ -31,6 +39,7 @@
"description": "Tests if one or more batteries are low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
@@ -40,6 +49,7 @@
"description": "Tests if one or more batteries are not charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
@@ -49,6 +59,7 @@
"description": "Tests if one or more batteries are not low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
@@ -76,6 +87,7 @@
"description": "Triggers after the battery level of one or more batteries changes.",
"fields": {
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_changed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
@@ -85,9 +97,11 @@
"description": "Triggers after the battery level of one or more batteries crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::battery::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::battery::common::trigger_threshold_name%]"
}
},
@@ -97,6 +111,7 @@
"description": "Triggers after one or more batteries become low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
@@ -106,6 +121,7 @@
"description": "Triggers after one or more batteries are no longer low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
@@ -115,6 +131,7 @@
"description": "Triggers after one or more batteries start charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},
@@ -124,6 +141,7 @@
"description": "Triggers after one or more batteries stop charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::trigger_behavior_description%]",
"name": "[%key:component::battery::common::trigger_behavior_name%]"
}
},

View File

@@ -1 +1 @@
"""The Bbox integration."""
"""The bbox component."""

View File

@@ -1 +1 @@
"""The Bitcoin integration."""
"""The bitcoin component."""

View File

@@ -1,7 +1,7 @@
{
"domain": "blebox",
"name": "BleBox devices",
"codeowners": ["@bbx-a", "@swistakm", "@bkobus-bbx"],
"codeowners": ["@bbx-a", "@swistakm"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/blebox",
"integration_type": "device",

View File

@@ -1 +1 @@
"""The BlinkStick integration."""
"""The blinksticklight component."""

View File

@@ -1,4 +1,4 @@
"""Support for BlinkStick lights."""
"""Support for Blinkstick lights."""
# mypy: ignore-errors
from __future__ import annotations
@@ -40,7 +40,7 @@ def setup_platform(
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up BlinkStick device specified by serial number."""
"""Set up Blinkstick device specified by serial number."""
name = config[CONF_NAME]
serial = config[CONF_SERIAL]

View File

@@ -1,41 +0,0 @@
"""The BMW Connected Drive integration."""
from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
DOMAIN = "bmw_connected_drive"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BMW Connected Drive from a config entry."""
ir.async_create_issue(
hass,
DOMAIN,
DOMAIN,
is_fixable=False,
severity=ir.IssueSeverity.ERROR,
translation_key="integration_removed",
translation_placeholders={
"entries": "/config/integrations/integration/bmw_connected_drive",
"custom_component_url": "https://github.com/kvanbiesen/bmw-cardata-ha",
},
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return True
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Remove a config entry."""
if not hass.config_entries.async_loaded_entries(DOMAIN):
ir.async_delete_issue(hass, DOMAIN, DOMAIN)
# Remove any remaining disabled or ignored entries
for _entry in hass.config_entries.async_entries(DOMAIN):
hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))

View File

@@ -1,9 +0,0 @@
"""The BMW Connected Drive integration config flow."""
from homeassistant.config_entries import ConfigFlow
from . import DOMAIN
class BMWConnectedDriveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for BMW Connected Drive."""

View File

@@ -1,10 +0,0 @@
{
"domain": "bmw_connected_drive",
"name": "BMW Connected Drive",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
"integration_type": "system",
"iot_class": "cloud_polling",
"quality_scale": "legacy",
"requirements": []
}

View File

@@ -1,8 +0,0 @@
{
"issues": {
"integration_removed": {
"description": "The BMW Connected Drive integration has been removed from Home Assistant.\n\nIn September 2025, BMW blocked third-party access to their servers by adding additional security measures. For EU-registered cars, a community-developed [custom component]({custom_component_url}) using BMW's CarData API is available as an alternative.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing BMW Connected Drive integration entries]({entries}).",
"title": "The BMW Connected Drive integration has been removed"
}
}
}

View File

@@ -52,9 +52,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Rotate the access token."""
access_tokens.append(hex(_RND.getrandbits(256))[2:])
async_track_time_interval(
hass, _rotate_token, TOKEN_CHANGE_INTERVAL, cancel_on_shutdown=True
)
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
hass.http.register_view(BrandsIntegrationView(hass))
hass.http.register_view(BrandsHardwareView(hass))

View File

@@ -1,12 +1,14 @@
{
"common": {
"condition_behavior_name": "Condition passes if"
"condition_behavior_description": "How the state should match on the targeted calendars.",
"condition_behavior_name": "Behavior"
},
"conditions": {
"is_event_active": {
"description": "Tests if one or more calendars have an active event.",
"fields": {
"behavior": {
"description": "[%key:component::calendar::common::condition_behavior_description%]",
"name": "[%key:component::calendar::common::condition_behavior_name%]"
}
},

View File

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

View File

@@ -30,7 +30,6 @@ class ChessConfigFlow(ConfigFlow, domain=DOMAIN):
client = ChessComClient(session=session)
try:
user = await client.get_player(user_input[CONF_USERNAME])
await client.get_player_stats(user_input[CONF_USERNAME])
except NotFoundError:
errors["base"] = "player_not_found"
except Exception:

View File

@@ -1,15 +1,21 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
"condition_behavior_description": "How the state should match on the targeted climate-control devices.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_cooling": {
"description": "Tests if one or more climate-control devices are cooling.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
@@ -19,6 +25,7 @@
"description": "Tests if one or more climate-control devices are drying.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
@@ -28,6 +35,7 @@
"description": "Tests if one or more climate-control devices are heating.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
@@ -37,6 +45,7 @@
"description": "Tests if one or more climate-control devices are set to a specific HVAC mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"hvac_mode": {
@@ -50,6 +59,7 @@
"description": "Tests if one or more climate-control devices are off.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
@@ -59,6 +69,7 @@
"description": "Tests if one or more climate-control devices are on.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
}
},
@@ -68,9 +79,11 @@
"description": "Tests the humidity setpoint of one or more climate-control devices.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::condition_threshold_description%]",
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
@@ -80,9 +93,11 @@
"description": "Tests the temperature setpoint of one or more climate-control devices.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::condition_threshold_description%]",
"name": "[%key:component::climate::common::condition_threshold_name%]"
}
},
@@ -383,6 +398,7 @@
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"hvac_mode": {
@@ -396,6 +412,7 @@
"description": "Triggers after one or more climate-control devices start cooling.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
@@ -405,6 +422,7 @@
"description": "Triggers after one or more climate-control devices start drying.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
@@ -414,6 +432,7 @@
"description": "Triggers after one or more climate-control devices start heating.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
@@ -423,6 +442,7 @@
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"fields": {
"threshold": {
"description": "[%key:component::climate::common::trigger_threshold_changed_description%]",
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
@@ -432,9 +452,11 @@
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
@@ -444,6 +466,7 @@
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": {
"threshold": {
"description": "[%key:component::climate::common::trigger_threshold_changed_description%]",
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
@@ -453,9 +476,11 @@
"description": "Triggers after the temperature setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::climate::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::climate::common::trigger_threshold_name%]"
}
},
@@ -465,6 +490,7 @@
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
@@ -474,6 +500,7 @@
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},

View File

@@ -210,7 +210,7 @@ def websocket_update_entity(
)
return
changes: dict[str, Any] = {}
changes = {}
for key in (
"area_id",

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,7 @@
{
"common": {
"trigger_behavior_name": "Trigger when"
},
"conditions": {
"is_value": {
"description": "Tests the value of one or more counters.",
"fields": {
"behavior": {
"name": "Condition passes if"
},
"threshold": {
"name": "Threshold type"
}
},
"name": "Counter value"
}
"trigger_behavior_description": "The behavior of the targeted counters to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
@@ -43,12 +30,6 @@
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
@@ -95,6 +76,7 @@
"description": "Triggers after one or more counters reach their maximum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
@@ -104,6 +86,7 @@
"description": "Triggers after one or more counters reach their minimum value.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},
@@ -113,6 +96,7 @@
"description": "Triggers after one or more counters are reset.",
"fields": {
"behavior": {
"description": "[%key:component::counter::common::trigger_behavior_description%]",
"name": "[%key:component::counter::common::trigger_behavior_name%]"
}
},

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted covers.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted covers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"awning_is_closed": {
"description": "Tests if one or more awnings are closed.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more awnings are open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -26,6 +30,7 @@
"description": "Tests if one or more blinds are closed.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -35,6 +40,7 @@
"description": "Tests if one or more blinds are open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -44,6 +50,7 @@
"description": "Tests if one or more curtains are closed.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -53,6 +60,7 @@
"description": "Tests if one or more curtains are open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -62,6 +70,7 @@
"description": "Tests if one or more shades are closed.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -71,6 +80,7 @@
"description": "Tests if one or more shades are open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -80,6 +90,7 @@
"description": "Tests if one or more shutters are closed.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -89,6 +100,7 @@
"description": "Tests if one or more shutters are open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::condition_behavior_description%]",
"name": "[%key:component::cover::common::condition_behavior_name%]"
}
},
@@ -253,6 +265,7 @@
"description": "Triggers after one or more awnings close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -262,6 +275,7 @@
"description": "Triggers after one or more awnings open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -271,6 +285,7 @@
"description": "Triggers after one or more blinds close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -280,6 +295,7 @@
"description": "Triggers after one or more blinds open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -289,6 +305,7 @@
"description": "Triggers after one or more curtains close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -298,6 +315,7 @@
"description": "Triggers after one or more curtains open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -307,6 +325,7 @@
"description": "Triggers after one or more shades close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -316,6 +335,7 @@
"description": "Triggers after one or more shades open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -325,6 +345,7 @@
"description": "Triggers after one or more shutters close.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},
@@ -334,6 +355,7 @@
"description": "Triggers after one or more shutters open.",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
}
},

View File

@@ -6,7 +6,7 @@
},
"services": {
"set_value": {
"description": "Sets the value of a date.",
"description": "Sets the date.",
"fields": {
"date": {
"description": "The date to set.",

View File

@@ -6,7 +6,7 @@
},
"services": {
"set_value": {
"description": "Sets the value of a date/time.",
"description": "Sets the date/time for a datetime entity.",
"fields": {
"datetime": {
"description": "The date/time to set. The time zone of the Home Assistant instance is assumed.",

View File

@@ -1 +1 @@
"""The Denon Network Receivers integration."""
"""The denon component."""

View File

@@ -1,4 +1,4 @@
"""The Denon AVR Network Receivers integration."""
"""The denonavr component."""
import logging

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted device trackers.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_home": {
"description": "Tests if one or more device trackers are home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more device trackers are not home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::condition_behavior_description%]",
"name": "[%key:component::device_tracker::common::condition_behavior_name%]"
}
},
@@ -125,6 +129,7 @@
"description": "Triggers when one or more device trackers enter home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
@@ -134,6 +139,7 @@
"description": "Triggers when one or more device trackers leave home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from devolo_home_control_api.devices.zwave import Zwave
from devolo_home_control_api.homecontrol import HomeControl
@@ -190,8 +188,6 @@ class DevoloConsumptionEntity(DevoloMultiLevelDeviceEntity):
def sync_callback(self, message: tuple) -> None:
"""Update the consumption sensor state."""
if message[0] == self._attr_unique_id:
if TYPE_CHECKING:
assert self._attr_unique_id is not None
self._value = getattr(
self._device_instance.consumption_property[self._attr_unique_id],
self._sensor_type,

View File

@@ -1,4 +1,4 @@
"""The DNS IP integration."""
"""The dnsip component."""
from __future__ import annotations
@@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload DNS IP config entry."""
"""Unload dnsip config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::condition_behavior_description%]",
"name": "[%key:component::door::common::condition_behavior_name%]"
}
},
@@ -44,6 +48,7 @@
"description": "Triggers after one or more doors close.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::trigger_behavior_description%]",
"name": "[%key:component::door::common::trigger_behavior_name%]"
}
},
@@ -53,6 +58,7 @@
"description": "Triggers after one or more doors open.",
"fields": {
"behavior": {
"description": "[%key:component::door::common::trigger_behavior_description%]",
"name": "[%key:component::door::common::trigger_behavior_name%]"
}
},

View File

@@ -0,0 +1,64 @@
"""The Dropbox integration."""
from __future__ import annotations
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxUnknownException,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from .auth import DropboxConfigEntryAuth
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
type DropboxConfigEntry = ConfigEntry[DropboxAPIClient]
async def async_setup_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Set up Dropbox from a config entry."""
try:
oauth2_implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth2_session = OAuth2Session(hass, entry, oauth2_implementation)
auth = DropboxConfigEntryAuth(
aiohttp_client.async_get_clientsession(hass), oauth2_session
)
client = DropboxAPIClient(auth)
try:
await client.get_account_info()
except DropboxAuthException as err:
raise ConfigEntryAuthFailed from err
except (DropboxUnknownException, TimeoutError) as err:
raise ConfigEntryNotReady from err
entry.runtime_data = client
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: DropboxConfigEntry) -> bool:
"""Unload a config entry."""
return True

View File

@@ -0,0 +1,38 @@
"""Application credentials platform for the Dropbox integration."""
from homeassistant.components.application_credentials import ClientCredential
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
AbstractOAuth2Implementation,
LocalOAuth2ImplementationWithPkce,
)
from .const import OAUTH2_AUTHORIZE, OAUTH2_SCOPES, OAUTH2_TOKEN
async def async_get_auth_implementation(
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
) -> AbstractOAuth2Implementation:
"""Return custom auth implementation."""
return DropboxOAuth2Implementation(
hass,
auth_domain,
credential.client_id,
OAUTH2_AUTHORIZE,
OAUTH2_TOKEN,
credential.client_secret,
)
class DropboxOAuth2Implementation(LocalOAuth2ImplementationWithPkce):
"""Custom Dropbox OAuth2 implementation to add the necessary authorize url parameters."""
@property
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
data: dict = {
"token_access_type": "offline",
"scope": " ".join(OAUTH2_SCOPES),
}
data.update(super().extra_authorize_data)
return data

View File

@@ -0,0 +1,44 @@
"""Authentication for Dropbox."""
from typing import cast
from aiohttp import ClientSession
from python_dropbox_api import Auth
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
class DropboxConfigEntryAuth(Auth):
"""Provide Dropbox authentication tied to an OAuth2 based config entry."""
def __init__(
self,
websession: ClientSession,
oauth_session: OAuth2Session,
) -> None:
"""Initialize DropboxConfigEntryAuth."""
super().__init__(websession)
self._oauth_session = oauth_session
async def async_get_access_token(self) -> str:
"""Return a valid access token."""
await self._oauth_session.async_ensure_token_valid()
return cast(str, self._oauth_session.token["access_token"])
class DropboxConfigFlowAuth(Auth):
"""Provide authentication tied to a fixed token for the config flow."""
def __init__(
self,
websession: ClientSession,
token: str,
) -> None:
"""Initialize DropboxConfigFlowAuth."""
super().__init__(websession)
self._token = token
async def async_get_access_token(self) -> str:
"""Return the fixed access token."""
return self._token

View File

@@ -0,0 +1,230 @@
"""Backup platform for the Dropbox integration."""
from collections.abc import AsyncIterator, Callable, Coroutine
from functools import wraps
import json
import logging
from typing import Any, Concatenate
from python_dropbox_api import (
DropboxAPIClient,
DropboxAuthException,
DropboxFileOrFolderNotFoundException,
DropboxUnknownException,
)
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from . import DropboxConfigEntry
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
_LOGGER = logging.getLogger(__name__)
def _suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
"""Return the suggested filenames for the backup and metadata."""
base_name = suggested_filename(backup).rsplit(".", 1)[0]
return f"{base_name}.tar", f"{base_name}.metadata.json"
async def _async_string_iterator(content: str) -> AsyncIterator[bytes]:
"""Yield a string as a single bytes chunk."""
yield content.encode()
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[DropboxBackupAgent, P], Coroutine[Any, Any, _R]]:
"""Handle backup errors."""
@wraps(func)
async def wrapper(
self: DropboxBackupAgent, *args: P.args, **kwargs: P.kwargs
) -> _R:
try:
return await func(self, *args, **kwargs)
except DropboxFileOrFolderNotFoundException as err:
raise BackupNotFound(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
except DropboxAuthException as err:
self._entry.async_start_reauth(self._hass)
raise BackupAgentError("Authentication error") from err
except DropboxUnknownException as err:
_LOGGER.error(
"Error during %s: %s",
func.__name__,
err,
)
_LOGGER.debug("Full error: %s", err, exc_info=True)
raise BackupAgentError(
f"Failed to {func.__name__.removeprefix('async_').replace('_', ' ')}"
) from err
return wrapper
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
) -> list[BackupAgent]:
"""Return a list of backup agents."""
entries = hass.config_entries.async_loaded_entries(DOMAIN)
return [DropboxBackupAgent(hass, entry) for entry in entries]
@callback
def async_register_backup_agents_listener(
hass: HomeAssistant,
*,
listener: Callable[[], None],
**kwargs: Any,
) -> Callable[[], None]:
"""Register a listener to be called when agents are added or removed.
:return: A function to unregister the listener.
"""
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
@callback
def remove_listener() -> None:
"""Remove the listener."""
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
del hass.data[DATA_BACKUP_AGENT_LISTENERS]
return remove_listener
class DropboxBackupAgent(BackupAgent):
"""Backup agent for the Dropbox integration."""
domain = DOMAIN
def __init__(self, hass: HomeAssistant, entry: DropboxConfigEntry) -> None:
"""Initialize the backup agent."""
super().__init__()
self._hass = hass
self._entry = entry
self.name = entry.title
assert entry.unique_id
self.unique_id = entry.unique_id
self._api: DropboxAPIClient = entry.runtime_data
async def _async_get_backups(self) -> list[tuple[AgentBackup, str]]:
"""Get backups and their corresponding file names."""
files = await self._api.list_folder("")
tar_files = {f.name for f in files if f.name.endswith(".tar")}
metadata_files = [f for f in files if f.name.endswith(".metadata.json")]
backups: list[tuple[AgentBackup, str]] = []
for metadata_file in metadata_files:
tar_name = metadata_file.name.removesuffix(".metadata.json") + ".tar"
if tar_name not in tar_files:
_LOGGER.warning(
"Found metadata file '%s' without matching backup file",
metadata_file.name,
)
continue
metadata_stream = self._api.download_file(f"/{metadata_file.name}")
raw = b"".join([chunk async for chunk in metadata_stream])
try:
data = json.loads(raw)
backup = AgentBackup.from_dict(data)
except (json.JSONDecodeError, ValueError, TypeError, KeyError) as err:
_LOGGER.warning(
"Skipping invalid metadata file '%s': %s",
metadata_file.name,
err,
)
continue
backups.append((backup, tar_name))
return backups
@handle_backup_errors
async def async_upload_backup(
self,
*,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
backup: AgentBackup,
**kwargs: Any,
) -> None:
"""Upload a backup."""
backup_filename, metadata_filename = _suggested_filenames(backup)
backup_path = f"/{backup_filename}"
metadata_path = f"/{metadata_filename}"
file_stream = await open_stream()
await self._api.upload_file(backup_path, file_stream)
metadata_stream = _async_string_iterator(json.dumps(backup.as_dict()))
try:
await self._api.upload_file(metadata_path, metadata_stream)
except (
DropboxAuthException,
DropboxUnknownException,
):
await self._api.delete_file(backup_path)
raise
@handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
return [backup for backup, _ in await self._async_get_backups()]
@handle_backup_errors
async def async_download_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
backups = await self._async_get_backups()
for backup, filename in backups:
if backup.backup_id == backup_id:
return self._api.download_file(f"/{filename}")
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
) -> AgentBackup:
"""Return a backup."""
backups = await self._async_get_backups()
for backup, _ in backups:
if backup.backup_id == backup_id:
return backup
raise BackupNotFound(f"Backup {backup_id} not found")
@handle_backup_errors
async def async_delete_backup(
self,
backup_id: str,
**kwargs: Any,
) -> None:
"""Delete a backup file."""
backups = await self._async_get_backups()
for backup, tar_filename in backups:
if backup.backup_id == backup_id:
metadata_filename = tar_filename.removesuffix(".tar") + ".metadata.json"
await self._api.delete_file(f"/{tar_filename}")
await self._api.delete_file(f"/{metadata_filename}")
return
raise BackupNotFound(f"Backup {backup_id} not found")

View File

@@ -0,0 +1,60 @@
"""Config flow for Dropbox."""
from collections.abc import Mapping
import logging
from typing import Any
from python_dropbox_api import DropboxAPIClient
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .auth import DropboxConfigFlowAuth
from .const import DOMAIN
class DropboxConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Dropbox OAuth2 authentication."""
DOMAIN = DOMAIN
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow, or update existing entry."""
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
auth = DropboxConfigFlowAuth(async_get_clientsession(self.hass), access_token)
client = DropboxAPIClient(auth)
account_info = await client.get_account_info()
await self.async_set_unique_id(account_info.account_id)
if self.source == SOURCE_REAUTH:
self._abort_if_unique_id_mismatch(reason="wrong_account")
return self.async_update_reload_and_abort(
self._get_reauth_entry(), data=data
)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=account_info.email, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Dialog that informs the user that reauth is required."""
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()

View File

@@ -0,0 +1,19 @@
"""Constants for the Dropbox integration."""
from collections.abc import Callable
from homeassistant.util.hass_dict import HassKey
DOMAIN = "dropbox"
OAUTH2_AUTHORIZE = "https://www.dropbox.com/oauth2/authorize"
OAUTH2_TOKEN = "https://api.dropboxapi.com/oauth2/token"
OAUTH2_SCOPES = [
"account_info.read",
"files.content.read",
"files.content.write",
]
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
f"{DOMAIN}.backup_agent_listeners"
)

View File

@@ -0,0 +1,13 @@
{
"domain": "dropbox",
"name": "Dropbox",
"after_dependencies": ["backup"],
"codeowners": ["@bdr99"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/dropbox",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["python-dropbox-api==0.1.3"]
}

View File

@@ -0,0 +1,112 @@
rules:
# Bronze
action-setup:
status: exempt
comment: Integration does not register any actions.
appropriate-polling:
status: exempt
comment: Integration does not poll.
brands: done
common-modules:
status: exempt
comment: Integration does not have any entities or coordinators.
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration does not register any actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: Integration does not have any entities.
entity-unique-id:
status: exempt
comment: Integration does not have any entities.
has-entity-name:
status: exempt
comment: Integration does not have any entities.
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: Integration does not register any actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: Integration does not have any configuration parameters.
docs-installation-parameters: done
entity-unavailable:
status: exempt
comment: Integration does not have any entities.
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: exempt
comment: Integration does not make any entity updates.
reauthentication-flow: done
test-coverage: done
# Gold
devices:
status: exempt
comment: Integration does not have any entities.
diagnostics:
status: exempt
comment: Integration does not have any data to diagnose.
discovery-update-info:
status: exempt
comment: Integration is a service.
discovery:
status: exempt
comment: Integration is a service.
docs-data-update:
status: exempt
comment: Integration does not update any data.
docs-examples:
status: exempt
comment: Integration only provides backup functionality.
docs-known-limitations: todo
docs-supported-devices:
status: exempt
comment: Integration does not support any devices.
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Integration does not use any devices.
entity-category:
status: exempt
comment: Integration does not have any entities.
entity-device-class:
status: exempt
comment: Integration does not have any entities.
entity-disabled-by-default:
status: exempt
comment: Integration does not have any entities.
entity-translations:
status: exempt
comment: Integration does not have any entities.
exception-translations: todo
icon-translations:
status: exempt
comment: Integration does not have any entities.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration does not have any repairs.
stale-devices:
status: exempt
comment: Integration does not have any devices.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -0,0 +1,35 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
"wrong_account": "Wrong account: Please authenticate with the correct account."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"step": {
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
"reauth_confirm": {
"description": "The Dropbox integration needs to re-authenticate your account.",
"title": "[%key:common::config_flow::title::reauth%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -25,7 +25,7 @@ def _fix_device_registry_identifiers(
if old_identifier not in device_entry.identifiers: # type: ignore[comparison-overlap]
continue
new_identifiers = device_entry.identifiers.copy()
new_identifiers.discard(old_identifier)
new_identifiers.discard(old_identifier) # type: ignore[arg-type]
new_identifiers.add((DOMAIN, entry.data["station"]))
device_registry.async_update_device(
device_entry.id, new_identifiers=new_identifiers

View File

@@ -1 +1 @@
"""The EBox integration."""
"""The ebox component."""

View File

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

View File

@@ -1 +1 @@
"""The Edimax integration."""
"""The edimax component."""

View File

@@ -273,7 +273,7 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
continue
# Build kwargs common to both modes
kwargs: dict[str, Any] = base_stream_params | {
kwargs = base_stream_params | {
"text": text,
}

View File

@@ -293,7 +293,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ElkM1ConfigEntry) -> boo
elk_temp_unit = elk.panel.temperature_units
if elk_temp_unit == "C":
temperature_unit = UnitOfTemperature.CELSIUS # type: ignore[unreachable]
temperature_unit = UnitOfTemperature.CELSIUS
else:
temperature_unit = UnitOfTemperature.FAHRENHEIT
config["temperature_unit"] = temperature_unit

View File

@@ -1 +1 @@
"""The Fail2Ban integration."""
"""The fail2ban component."""

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted fans.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted fans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Tests if one or more fans are off.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::condition_behavior_description%]",
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more fans are on.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::condition_behavior_description%]",
"name": "[%key:component::fan::common::condition_behavior_name%]"
}
},
@@ -195,6 +199,7 @@
"description": "Triggers after one or more fans turn off.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
@@ -204,6 +209,7 @@
"description": "Triggers after one or more fans turn on.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},

View File

@@ -1 +1 @@
"""The Fido integration."""
"""The fido component."""

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
"""Fortinet FortiOS integration."""
"""Fortinet FortiOS components."""

View File

@@ -1,6 +1,6 @@
"""Support to use FortiOS device like FortiGate as device tracker.
This FortiOS integration provides a device_tracker platform.
This component is part of the device_tracker platform.
"""
from __future__ import annotations

View File

@@ -30,31 +30,22 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
MINOR_VERSION = 1
async def _validate_input(self, username: str, password: str) -> str | None:
"""Validate credentials, returning an error key or None on success."""
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(username, password)
except LoginError:
return "invalid_auth"
except ClientError:
return "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return "unknown"
return None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
error = await self._validate_input(
user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(user_input[CONF_USERNAME].lower())
self._abort_if_unique_id_configured()
@@ -67,34 +58,6 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, 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:
error = await self._validate_input(
reconfigure_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
else:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=vol.Schema({vol.Required(CONF_PASSWORD): str}),
description_placeholders={
CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]
},
errors=errors,
)
async def async_step_reauth(
self, _user_input: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -109,11 +72,18 @@ class FreshrFlowHandler(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input is not None:
error = await self._validate_input(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
if error:
errors["base"] = error
client = FreshrClient(session=async_get_clientsession(self.hass))
try:
await client.login(
reauth_entry.data[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except LoginError:
errors["base"] = "invalid_auth"
except ClientError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,

View File

@@ -1,34 +0,0 @@
"""Diagnostics support for Fresh-r."""
from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant
from .coordinator import FreshrConfigEntry
TO_REDACT = {CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: FreshrConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
runtime_data = entry.runtime_data
return {
"entry": async_redact_data(entry.as_dict(), TO_REDACT),
"devices": [
dataclasses.asdict(device) for device in runtime_data.devices.data.values()
],
"readings": {
device_id: dataclasses.asdict(coordinator.data)
if coordinator.data is not None
else None
for device_id, coordinator in runtime_data.readings.items()
},
}

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: Integration connects to a cloud service; no local network discovery is possible.
@@ -62,7 +62,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: No actionable repair scenarios exist; authentication failures are handled via the reauthentication flow.

View File

@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -20,15 +19,6 @@
},
"description": "Re-enter the password for your Fresh-r account `{username}`."
},
"reconfigure": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::freshr::config::step::user::data_description::password%]"
},
"description": "Update the password for your Fresh-r account `{username}`."
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260325.4"]
"requirements": ["home-assistant-frontend==20260325.2"]
}

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted garage doors.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more garage doors are closed.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more garage doors are open.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::condition_behavior_description%]",
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
}
},
@@ -44,6 +48,7 @@
"description": "Triggers after one or more garage doors close.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
}
},
@@ -53,6 +58,7 @@
"description": "Triggers after one or more garage doors open.",
"fields": {
"behavior": {
"description": "[%key:component::garage_door::common::trigger_behavior_description%]",
"name": "[%key:component::garage_door::common::trigger_behavior_name%]"
}
},

View File

@@ -1,13 +1,16 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted gates.",
"condition_behavior_name": "Behavior",
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_closed": {
"description": "Tests if one or more gates are closed.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
@@ -17,6 +20,7 @@
"description": "Tests if one or more gates are open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::condition_behavior_description%]",
"name": "[%key:component::gate::common::condition_behavior_name%]"
}
},
@@ -44,6 +48,7 @@
"description": "Triggers after one or more gates close.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},
@@ -53,6 +58,7 @@
"description": "Triggers after one or more gates open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},

View File

@@ -1,4 +1,4 @@
"""The Glances integration."""
"""The Glances component."""
import logging
from typing import Any

View File

@@ -1,4 +1,4 @@
"""Constants for Glances integration."""
"""Constants for Glances component."""
from datetime import timedelta
import sys

View File

@@ -1,4 +1,4 @@
"""Support gathering system information of hosts which are running Glances."""
"""Support gathering system information of hosts which are running glances."""
from __future__ import annotations

View File

@@ -3,12 +3,14 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
import re
import struct
from typing import Any, cast
from typing import Any, NamedTuple, cast
from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
@@ -39,23 +41,35 @@ from homeassistant.components.http import (
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, callback
from homeassistant.core import (
Event,
HassJob,
HomeAssistant,
ServiceCall,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
discovery_flow,
issue_registry as ir,
selector,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import create_eager_task
from homeassistant.util.dt import now
# config_flow, diagnostics, system_health, and entity platforms are imported to
# ensure other dependencies that wait for hassio are not waiting
@@ -78,7 +92,19 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_REPOSITORIES,
ATTR_SLUG,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
@@ -92,6 +118,7 @@ from .const import (
DATA_SUPERVISOR_INFO,
DOMAIN,
HASSIO_UPDATE_INTERVAL,
SupervisorEntityModel,
)
from .coordinator import (
HassioDataUpdateCoordinator,
@@ -109,11 +136,15 @@ from .coordinator import (
get_supervisor_stats,
)
from .discovery import async_setup_discovery_view
from .handler import HassIO, async_update_diagnostics, get_supervisor_client
from .handler import (
HassIO,
HassioAPIError,
async_update_diagnostics,
get_supervisor_client,
)
from .http import HassIOView
from .ingress import async_setup_ingress_view
from .issues import SupervisorIssues
from .services import async_setup_services
from .websocket_api import async_load_websocket_api
# Expose the future safe name now so integrations can use it
@@ -159,6 +190,23 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
DEPRECATION_URL = (
"https://www.home-assistant.io/blog/2025/05/22/"
@@ -166,11 +214,148 @@ DEPRECATION_URL = (
)
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG]
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
def _is_32_bit() -> bool:
size = struct.calcsize("P")
return size * 8 == 32
class APIEndpointSettings(NamedTuple):
"""Settings for API endpoint."""
command: str
schema: vol.Schema
timeout: int | None = 60
pass_data: bool = False
MAP_SERVICE_API = {
# Legacy addon services
SERVICE_ADDON_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_ADDON),
SERVICE_ADDON_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_ADDON),
SERVICE_ADDON_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_ADDON),
SERVICE_ADDON_STDIN: APIEndpointSettings(
"/addons/{addon}/stdin", SCHEMA_ADDON_STDIN
),
# New app services
SERVICE_APP_START: APIEndpointSettings("/addons/{addon}/start", SCHEMA_APP),
SERVICE_APP_STOP: APIEndpointSettings("/addons/{addon}/stop", SCHEMA_APP),
SERVICE_APP_RESTART: APIEndpointSettings("/addons/{addon}/restart", SCHEMA_APP),
SERVICE_APP_STDIN: APIEndpointSettings("/addons/{addon}/stdin", SCHEMA_APP_STDIN),
SERVICE_HOST_SHUTDOWN: APIEndpointSettings("/host/shutdown", SCHEMA_NO_DATA),
SERVICE_HOST_REBOOT: APIEndpointSettings("/host/reboot", SCHEMA_NO_DATA),
SERVICE_BACKUP_FULL: APIEndpointSettings(
"/backups/new/full",
SCHEMA_BACKUP_FULL,
None,
True,
),
SERVICE_BACKUP_PARTIAL: APIEndpointSettings(
"/backups/new/partial",
SCHEMA_BACKUP_PARTIAL,
None,
True,
),
SERVICE_RESTORE_FULL: APIEndpointSettings(
"/backups/{slug}/restore/full",
SCHEMA_RESTORE_FULL,
None,
True,
),
SERVICE_RESTORE_PARTIAL: APIEndpointSettings(
"/backups/{slug}/restore/partial",
SCHEMA_RESTORE_PARTIAL,
None,
True,
),
}
HARDWARE_INTEGRATIONS = {
"green": "homeassistant_green",
"odroid-c2": "hardkernel",
@@ -212,7 +397,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
host = os.environ["SUPERVISOR"]
websession = async_get_clientsession(hass)
hass.data[DATA_COMPONENT] = HassIO(hass.loop, websession, host)
hass.data[DATA_COMPONENT] = hassio = HassIO(hass.loop, websession, host)
supervisor_client = get_supervisor_client(hass)
try:
@@ -325,8 +510,74 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
# Register services
async_setup_services(hass, supervisor_client)
async def async_service_handler(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
api_endpoint = MAP_SERVICE_API[service.service]
data = service.data.copy()
addon = data.pop(ATTR_APP, None) or data.pop(ATTR_ADDON, None)
slug = data.pop(ATTR_SLUG, None)
if addons := data.pop(ATTR_APPS, None) or data.pop(ATTR_ADDONS, None):
data[ATTR_ADDONS] = addons
payload = None
# Pass data to Hass.io API
if service.service in (SERVICE_ADDON_STDIN, SERVICE_APP_STDIN):
payload = data[ATTR_INPUT]
elif api_endpoint.pass_data:
payload = data
# Call API
# The exceptions are logged properly in hassio.send_command
with suppress(HassioAPIError):
await hassio.send_command(
api_endpoint.command.format(addon=addon, slug=slug),
payload=payload,
timeout=api_endpoint.timeout,
)
for service, settings in MAP_SERVICE_API.items():
hass.services.async_register(
DOMAIN, service, async_service_handler, schema=settings.schema
)
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)
async def update_info_data(_: datetime | None = None) -> None:
"""Update last available supervisor information."""

View File

@@ -26,7 +26,7 @@ from aiohasupervisor.models import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from .handler import get_supervisor_client
from .handler import HassioAPIError, get_supervisor_client
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], Awaitable[_R]]
type _ReturnFuncType[_T, **_P, _R] = Callable[
@@ -36,15 +36,18 @@ type _ReturnFuncType[_T, **_P, _R] = Callable[
def api_error[_AddonManagerT: AddonManager, **_P, _R](
error_message: str,
*,
expected_error_type: type[HassioAPIError | SupervisorError] | None = None,
) -> Callable[
[_FuncType[_AddonManagerT, _P, _R]], _ReturnFuncType[_AddonManagerT, _P, _R]
]:
"""Handle SupervisorError and raise a specific AddonError."""
"""Handle HassioAPIError and raise a specific AddonError."""
error_type = expected_error_type or (HassioAPIError, SupervisorError)
def handle_supervisor_error(
def handle_hassio_api_error(
func: _FuncType[_AddonManagerT, _P, _R],
) -> _ReturnFuncType[_AddonManagerT, _P, _R]:
"""Handle a SupervisorError."""
"""Handle a HassioAPIError."""
@wraps(func)
async def wrapper(
@@ -53,7 +56,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
"""Wrap an add-on manager method."""
try:
return_value = await func(self, *args, **kwargs)
except SupervisorError as err:
except error_type as err:
raise AddonError(
f"{error_message.format(addon_name=self.addon_name)}: {err}"
) from err
@@ -62,7 +65,7 @@ def api_error[_AddonManagerT: AddonManager, **_P, _R](
return wrapper
return handle_supervisor_error
return handle_hassio_api_error
@dataclass
@@ -125,7 +128,10 @@ class AddonManager:
)
)
@api_error("Failed to get the {addon_name} app discovery info")
@api_error(
"Failed to get the {addon_name} app discovery info",
expected_error_type=SupervisorError,
)
async def async_get_addon_discovery_info(self) -> dict:
"""Return add-on discovery info."""
discovery_info = next(
@@ -142,7 +148,10 @@ class AddonManager:
return discovery_info.config
@api_error("Failed to get the {addon_name} app info")
@api_error(
"Failed to get the {addon_name} app info",
expected_error_type=SupervisorError,
)
async def async_get_addon_info(self) -> AddonInfo:
"""Return and cache manager add-on info."""
addon_store_info = await self._supervisor_client.store.addon_info(
@@ -190,14 +199,19 @@ class AddonManager:
version=addon_info.version,
)
@api_error("Failed to set the {addon_name} app options")
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
)
async def async_set_addon_options(self, config: dict) -> None:
"""Set manager add-on options."""
await self._supervisor_client.addons.set_addon_options(
self.addon_slug, AddonsOptions(config=config)
)
@api_error("Failed to install the {addon_name} app")
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
try:
@@ -207,7 +221,10 @@ class AddonManager:
f"{self.addon_name} app is not available: {err!s}"
) from None
@api_error("Failed to uninstall the {addon_name} app")
@api_error(
"Failed to uninstall the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_uninstall_addon(self) -> None:
"""Uninstall the managed add-on."""
await self._supervisor_client.addons.uninstall_addon(self.addon_slug)
@@ -242,22 +259,31 @@ class AddonManager:
self.addon_slug, StoreAddonUpdate(backup=False)
)
@api_error("Failed to start the {addon_name} app")
@api_error(
"Failed to start the {addon_name} app", expected_error_type=SupervisorError
)
async def async_start_addon(self) -> None:
"""Start the managed add-on."""
await self._supervisor_client.addons.start_addon(self.addon_slug)
@api_error("Failed to restart the {addon_name} app")
@api_error(
"Failed to restart the {addon_name} app", expected_error_type=SupervisorError
)
async def async_restart_addon(self) -> None:
"""Restart the managed add-on."""
await self._supervisor_client.addons.restart_addon(self.addon_slug)
@api_error("Failed to stop the {addon_name} app")
@api_error(
"Failed to stop the {addon_name} app", expected_error_type=SupervisorError
)
async def async_stop_addon(self) -> None:
"""Stop the managed add-on."""
await self._supervisor_client.addons.stop_addon(self.addon_slug)
@api_error("Failed to create a backup of the {addon_name} app")
@api_error(
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
"""Create a partial backup of the managed add-on."""
if addon_info:

View File

@@ -1,439 +0,0 @@
"""Set up Supervisor services."""
from collections.abc import Awaitable, Callable
import json
import re
from typing import Any
from aiohasupervisor import SupervisorClient, SupervisorError
from aiohasupervisor.models import (
FullBackupOptions,
FullRestoreOptions,
PartialBackupOptions,
PartialRestoreOptions,
)
import voluptuous as vol
from homeassistant.const import ATTR_DEVICE_ID, ATTR_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
ServiceResponse,
SupportsResponse,
async_get_hass_or_none,
callback,
)
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
selector,
)
from homeassistant.util.dt import now
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
ATTR_ADDONS,
ATTR_APP,
ATTR_APPS,
ATTR_COMPRESSED,
ATTR_FOLDERS,
ATTR_HOMEASSISTANT,
ATTR_HOMEASSISTANT_EXCLUDE_DATABASE,
ATTR_INPUT,
ATTR_LOCATION,
ATTR_PASSWORD,
ATTR_SLUG,
DOMAIN,
SupervisorEntityModel,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_info
SERVICE_ADDON_START = "addon_start"
SERVICE_ADDON_STOP = "addon_stop"
SERVICE_ADDON_RESTART = "addon_restart"
SERVICE_ADDON_STDIN = "addon_stdin"
SERVICE_APP_START = "app_start"
SERVICE_APP_STOP = "app_stop"
SERVICE_APP_RESTART = "app_restart"
SERVICE_APP_STDIN = "app_stdin"
SERVICE_HOST_SHUTDOWN = "host_shutdown"
SERVICE_HOST_REBOOT = "host_reboot"
SERVICE_BACKUP_FULL = "backup_full"
SERVICE_BACKUP_PARTIAL = "backup_partial"
SERVICE_RESTORE_FULL = "restore_full"
SERVICE_RESTORE_PARTIAL = "restore_partial"
SERVICE_MOUNT_RELOAD = "mount_reload"
VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$"))
def valid_addon(value: Any) -> str:
"""Validate value is a valid addon slug."""
value = VALID_ADDON_SLUG(value)
hass = async_get_hass_or_none()
if hass and (addons := get_addons_info(hass)) is not None and value not in addons:
raise vol.Invalid("Not a valid app slug")
return value
SCHEMA_NO_DATA = vol.Schema({})
SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon})
SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_APP = vol.Schema({vol.Required(ATTR_APP): valid_addon})
SCHEMA_APP_STDIN = SCHEMA_APP.extend(
{vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)}
)
SCHEMA_BACKUP_FULL = vol.Schema(
{
vol.Optional(
ATTR_NAME, default=lambda: now().strftime("%Y-%m-%d %H:%M:%S")
): cv.string,
vol.Optional(ATTR_PASSWORD): cv.string,
vol.Optional(ATTR_COMPRESSED): cv.boolean,
vol.Optional(ATTR_LOCATION): vol.All(
cv.string, lambda v: None if v == "/backup" else v
),
vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean,
}
)
SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_RESTORE_FULL = vol.Schema(
{
vol.Required(ATTR_SLUG): cv.slug,
vol.Optional(ATTR_PASSWORD): cv.string,
}
)
SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend(
{
vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
vol.Optional(ATTR_FOLDERS): vol.All(
cv.ensure_list, [cv.string], vol.Unique(), vol.Coerce(set)
),
vol.Exclusive(ATTR_APPS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
# Legacy "addons", "apps" is preferred
vol.Exclusive(ATTR_ADDONS, "apps_or_addons"): vol.All(
cv.ensure_list, [VALID_ADDON_SLUG], vol.Unique(), vol.Coerce(set)
),
}
)
SCHEMA_MOUNT_RELOAD = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): selector.DeviceSelector(
selector.DeviceSelectorConfig(
filter=selector.DeviceFilterSelectorConfig(
integration=DOMAIN,
model=SupervisorEntityModel.MOUNT,
)
)
)
}
)
@callback
def async_setup_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register the Supervisor services."""
async_register_app_services(hass, supervisor_client)
async_register_host_services(hass, supervisor_client)
async_register_backup_restore_services(hass, supervisor_client)
async_register_network_storage_services(hass, supervisor_client)
@callback
def async_register_app_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register app services."""
simple_app_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_APP_START: ("start", supervisor_client.addons.start_addon),
SERVICE_APP_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_APP_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_app_service_handler(service: ServiceCall) -> None:
"""Handles app services which only take a slug and have no response."""
action, api_method = simple_app_services[service.service]
app_slug = service.data[ATTR_APP]
try:
await api_method(app_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {app_slug}: {err}"
) from err
for service in simple_app_services:
hass.services.async_register(
DOMAIN, service, async_simple_app_service_handler, schema=SCHEMA_APP
)
async def async_app_stdin_service_handler(service: ServiceCall) -> None:
"""Handles app stdin service."""
app_slug = service.data[ATTR_APP]
data: dict | str = service.data[ATTR_INPUT]
# For backwards compatibility the payload here must be valid json
# This is sensible when a dictionary is provided, it must be serialized
# If user provides a string though, we wrap it in quotes before encoding
# This is purely for legacy reasons, Supervisor has no json requirement
# Supervisor just hands the raw request as binary to the container
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(app_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {app_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_APP_STDIN,
async_app_stdin_service_handler,
schema=SCHEMA_APP_STDIN,
)
# LEGACY - Register equivalent addon services for compatibility
simple_addon_services: dict[str, tuple[str, Callable[[str], Awaitable[None]]]] = {
SERVICE_ADDON_START: ("start", supervisor_client.addons.start_addon),
SERVICE_ADDON_RESTART: ("restart", supervisor_client.addons.restart_addon),
SERVICE_ADDON_STOP: ("stop", supervisor_client.addons.stop_addon),
}
async def async_simple_addon_service_handler(service: ServiceCall) -> None:
"""Handles addon services which only take a slug and have no response."""
action, api_method = simple_addon_services[service.service]
addon_slug = service.data[ATTR_ADDON]
try:
await api_method(addon_slug)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to {action} app {addon_slug}: {err}"
) from err
for service in simple_addon_services:
hass.services.async_register(
DOMAIN, service, async_simple_addon_service_handler, schema=SCHEMA_ADDON
)
async def async_addon_stdin_service_handler(service: ServiceCall) -> None:
"""Handles addon stdin service."""
addon_slug = service.data[ATTR_ADDON]
data: dict | str = service.data[ATTR_INPUT]
# See explanation for why we make strings into json in async_app_stdin_service_handler
data = json.dumps(data)
payload = data.encode(encoding="utf-8")
try:
await supervisor_client.addons.write_addon_stdin(addon_slug, payload)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to write stdin to app {addon_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_ADDON_STDIN,
async_addon_stdin_service_handler,
schema=SCHEMA_ADDON_STDIN,
)
@callback
def async_register_host_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register host services."""
simple_host_services: dict[str, tuple[str, Callable[[], Awaitable[None]]]] = {
SERVICE_HOST_REBOOT: ("reboot", supervisor_client.host.reboot),
SERVICE_HOST_SHUTDOWN: ("shutdown", supervisor_client.host.shutdown),
}
async def async_simple_host_service_handler(service: ServiceCall) -> None:
"""Handler for host services that take no input and return no response."""
action, api_method = simple_host_services[service.service]
try:
await api_method()
except SupervisorError as err:
raise HomeAssistantError(f"Failed to {action} the host: {err}") from err
for service in simple_host_services:
hass.services.async_register(
DOMAIN, service, async_simple_host_service_handler, schema=SCHEMA_NO_DATA
)
@callback
def async_register_backup_restore_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register backup and restore services."""
async def async_full_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create full backup service. Returns the new backup's ID."""
options = FullBackupOptions(**service.data)
try:
backup = await supervisor_client.backups.full_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create full backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_FULL,
async_full_backup_service_handler,
schema=SCHEMA_BACKUP_FULL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_partial_backup_service_handler(
service: ServiceCall,
) -> ServiceResponse:
"""Handler for create partial backup service. Returns the new backup's ID."""
data = service.data.copy()
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialBackupOptions(**data)
try:
backup = await supervisor_client.backups.partial_backup(options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to create partial backup {options.name}: {err}"
) from err
return {"backup": backup.slug}
hass.services.async_register(
DOMAIN,
SERVICE_BACKUP_PARTIAL,
async_partial_backup_service_handler,
schema=SCHEMA_BACKUP_PARTIAL,
supports_response=SupportsResponse.OPTIONAL,
)
async def async_full_restore_service_handler(service: ServiceCall) -> None:
"""Handler for full restore service."""
backup_slug = service.data[ATTR_SLUG]
options: FullRestoreOptions | None = None
if ATTR_PASSWORD in service.data:
options = FullRestoreOptions(password=service.data[ATTR_PASSWORD])
try:
await supervisor_client.backups.full_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to full restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_FULL,
async_full_restore_service_handler,
schema=SCHEMA_RESTORE_FULL,
)
async def async_partial_restore_service_handler(service: ServiceCall) -> None:
"""Handler for partial restore service."""
data = service.data.copy()
backup_slug = data.pop(ATTR_SLUG)
if ATTR_APPS in data:
data[ATTR_ADDONS] = data.pop(ATTR_APPS)
options = PartialRestoreOptions(**data)
try:
await supervisor_client.backups.partial_restore(backup_slug, options)
except SupervisorError as err:
raise HomeAssistantError(
f"Failed to partial restore from backup {backup_slug}: {err}"
) from err
hass.services.async_register(
DOMAIN,
SERVICE_RESTORE_PARTIAL,
async_partial_restore_service_handler,
schema=SCHEMA_RESTORE_PARTIAL,
)
@callback
def async_register_network_storage_services(
hass: HomeAssistant, supervisor_client: SupervisorClient
) -> None:
"""Register network storage (or mount) services."""
dev_reg = dr.async_get(hass)
async def async_mount_reload(service: ServiceCall) -> None:
"""Handle service calls for Hass.io."""
coordinator: HassioDataUpdateCoordinator | None = None
if (device := dev_reg.async_get(service.data[ATTR_DEVICE_ID])) is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_unknown_device_id",
)
if (
device.name is None
or device.model != SupervisorEntityModel.MOUNT
or (coordinator := hass.data.get(ADDONS_COORDINATOR)) is None
or coordinator.entry_id not in device.config_entries
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="mount_reload_invalid_device",
)
try:
await supervisor_client.mounts.reload_mount(device.name)
except SupervisorError as error:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="mount_reload_error",
translation_placeholders={"name": device.name, "error": str(error)},
) from error
hass.services.async_register(
DOMAIN, SERVICE_MOUNT_RELOAD, async_mount_reload, SCHEMA_MOUNT_RELOAD
)

View File

@@ -18,6 +18,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from . import HassioAPIError
from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
@@ -39,7 +40,6 @@ from .const import (
WS_TYPE_SUBSCRIBE,
)
from .coordinator import get_addons_list
from .handler import HassioAPIError
from .update_helper import update_addon, update_core
SCHEMA_WEBSOCKET_EVENT = vol.Schema(

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"serialx==1.1.1",
"serialx==0.6.2",
"universal-silabs-flasher==1.0.3",
"ha-silabs-firmware-client==0.3.0"
]

View File

@@ -979,7 +979,7 @@ class HomeKit:
for entry in dev_reg.devices.get_devices_for_config_entry_id(self._entry_id)
if (
identifier not in entry.identifiers # type: ignore[comparison-overlap]
or connection not in entry.connections # type: ignore[unreachable]
or connection not in entry.connections
)
]

View File

@@ -4,23 +4,14 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .services import async_setup_services
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.EVENT, Platform.NOTIFY]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the HTML5 services."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up HTML5 from a config entry."""
hass.async_create_task(

View File

@@ -11,18 +11,5 @@ ATTR_VAPID_EMAIL = "vapid_email"
REGISTRATIONS_FILE = "html5_push_registrations.conf"
ATTR_ACTION = "action"
ATTR_ACTIONS = "actions"
ATTR_BADGE = "badge"
ATTR_DATA = "data"
ATTR_DIR = "dir"
ATTR_ICON = "icon"
ATTR_IMAGE = "image"
ATTR_LANG = "lang"
ATTR_RENOTIFY = "renotify"
ATTR_REQUIRE_INTERACTION = "require_interaction"
ATTR_SILENT = "silent"
ATTR_TAG = "tag"
ATTR_TIMESTAMP = "timestamp"
ATTR_TTL = "ttl"
ATTR_URGENCY = "urgency"
ATTR_VIBRATE = "vibrate"

View File

@@ -9,12 +9,6 @@
"services": {
"dismiss": {
"service": "mdi:bell-off"
},
"dismiss_message": {
"service": "mdi:comment-remove"
},
"send_message": {
"service": "mdi:comment-arrow-right"
}
}
}

View File

@@ -1,54 +0,0 @@
"""Issues for HTML5 integration."""
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import slugify
from .const import DOMAIN
@callback
def deprecated_notify_action_call(
hass: HomeAssistant, target: list[str] | None
) -> None:
"""Deprecated action call."""
action = (
f"notify.html5_{slugify(target[0])}"
if target and len(target) == 1
else "notify.html5"
)
async_create_issue(
hass,
DOMAIN,
f"deprecated_notify_action_{action}",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_notify_action",
translation_placeholders={
"action": action,
"new_action_1": "notify.send_message",
"new_action_2": "html5.send_message",
},
)
@callback
def deprecated_dismiss_action_call(hass: HomeAssistant) -> None:
"""Deprecated action call."""
async_create_issue(
hass,
DOMAIN,
"deprecated_dismiss_action",
breaks_in_ha_version="2026.11.0",
is_fixable=False,
severity=IssueSeverity.WARNING,
translation_key="deprecated_dismiss_action",
translation_placeholders={
"action": "html5.dismiss",
"new_action": "html5.dismiss_message",
},
)

View File

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

View File

@@ -47,11 +47,7 @@ from homeassistant.util.json import load_json_object
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_REQUIRE_INTERACTION,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_VAPID_EMAIL,
ATTR_VAPID_PRV_KEY,
ATTR_VAPID_PUB_KEY,
@@ -60,7 +56,6 @@ from .const import (
SERVICE_DISMISS,
)
from .entity import HTML5Entity, Registration
from .issue import deprecated_dismiss_action_call, deprecated_notify_action_call
_LOGGER = logging.getLogger(__name__)
@@ -74,11 +69,13 @@ ATTR_AUTH = "auth"
ATTR_P256DH = "p256dh"
ATTR_EXPIRATIONTIME = "expirationTime"
ATTR_ACTIONS = "actions"
ATTR_TYPE = "type"
ATTR_URL = "url"
ATTR_DISMISS = "dismiss"
ATTR_PRIORITY = "priority"
DEFAULT_PRIORITY = "normal"
ATTR_TTL = "ttl"
DEFAULT_TTL = 86400
DEFAULT_BADGE = "/static/images/notification-badge.png"
@@ -460,9 +457,6 @@ class HTML5NotificationService(BaseNotificationService):
This method must be run in the event loop.
"""
deprecated_dismiss_action_call(self.hass)
data: dict[str, Any] | None = kwargs.get(ATTR_DATA)
tag: str = data.get(ATTR_TAG, "") if data else ""
payload = {ATTR_TAG: tag, ATTR_DISMISS: True, ATTR_DATA: {}}
@@ -471,9 +465,6 @@ class HTML5NotificationService(BaseNotificationService):
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a user."""
deprecated_notify_action_call(self.hass, kwargs.get(ATTR_TARGET))
tag = str(uuid.uuid4())
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
@@ -614,58 +605,32 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
_key = "device"
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a device via notify.send_message action."""
await self._webpush(
title=title or ATTR_TITLE_DEFAULT,
message=message,
badge=DEFAULT_BADGE,
icon=DEFAULT_ICON,
)
"""Send a message to a device."""
timestamp = int(time.time())
tag = str(uuid.uuid4())
async def send_push_notification(self, **kwargs: Any) -> None:
"""Send a message to a device via html5.send_message action."""
await self._webpush(**kwargs)
self._async_record_notification()
async def dismiss_notification(self, tag: str = "") -> None:
"""Dismiss a message via html5.dismiss_message action."""
await self._webpush(dismiss=True, tag=tag)
self._async_record_notification()
async def _webpush(
self,
message: str | None = None,
timestamp: datetime | None = None,
ttl: timedelta | None = None,
urgency: str | None = None,
**kwargs: Any,
) -> None:
"""Shared internal helper to push messages."""
payload: dict[str, Any] = kwargs
if message is not None:
payload["body"] = message
payload.setdefault(ATTR_TAG, str(uuid.uuid4()))
ts = int(timestamp.timestamp()) if timestamp else int(time.time())
payload[ATTR_TIMESTAMP] = ts * 1000
if ATTR_REQUIRE_INTERACTION in payload:
payload["requireInteraction"] = payload.pop(ATTR_REQUIRE_INTERACTION)
payload.setdefault(ATTR_DATA, {})
payload[ATTR_DATA][ATTR_JWT] = add_jwt(
ts,
self.target,
payload[ATTR_TAG],
self.registration["subscription"]["keys"]["auth"],
)
payload: dict[str, Any] = {
"badge": DEFAULT_BADGE,
"body": message,
"icon": DEFAULT_ICON,
ATTR_TAG: tag,
ATTR_TITLE: title or ATTR_TITLE_DEFAULT,
"timestamp": timestamp * 1000,
ATTR_DATA: {
ATTR_JWT: add_jwt(
timestamp,
self.target,
tag,
self.registration["subscription"]["keys"]["auth"],
)
},
}
endpoint = urlparse(self.registration["subscription"]["endpoint"])
vapid_claims = {
"sub": f"mailto:{self.config_entry.data[ATTR_VAPID_EMAIL]}",
"aud": f"{endpoint.scheme}://{endpoint.netloc}",
"exp": ts + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
"exp": timestamp + (VAPID_CLAIM_VALID_HOURS * 60 * 60),
}
try:
@@ -674,8 +639,6 @@ class HTML5NotifyEntity(HTML5Entity, NotifyEntity):
json.dumps(payload),
self.config_entry.data[ATTR_VAPID_PRV_KEY],
vapid_claims,
ttl=int(ttl.total_seconds()) if ttl is not None else DEFAULT_TTL,
headers={"Urgency": urgency} if urgency else None,
aiohttp_session=self.session,
)
cast(ClientResponse, response).raise_for_status()

View File

@@ -1,95 +0,0 @@
"""Service registration for HTML5 integration."""
import voluptuous as vol
from homeassistant.components.notify import (
ATTR_DATA,
ATTR_MESSAGE,
ATTR_TITLE,
ATTR_TITLE_DEFAULT,
DOMAIN as NOTIFY_DOMAIN,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import (
ATTR_ACTION,
ATTR_ACTIONS,
ATTR_BADGE,
ATTR_DIR,
ATTR_ICON,
ATTR_IMAGE,
ATTR_LANG,
ATTR_RENOTIFY,
ATTR_REQUIRE_INTERACTION,
ATTR_SILENT,
ATTR_TAG,
ATTR_TIMESTAMP,
ATTR_TTL,
ATTR_URGENCY,
ATTR_VIBRATE,
DOMAIN,
)
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_DISMISS_MESSAGE = "dismiss_message"
SERVICE_SEND_MESSAGE_SCHEMA = cv.make_entity_service_schema(
{
vol.Required(ATTR_TITLE, default=ATTR_TITLE_DEFAULT): cv.string,
vol.Optional(ATTR_MESSAGE): cv.string,
vol.Optional(ATTR_DIR): vol.In({"auto", "ltr", "rtl"}),
vol.Optional(ATTR_ICON): cv.string,
vol.Optional(ATTR_BADGE): cv.string,
vol.Optional(ATTR_IMAGE): cv.string,
vol.Optional(ATTR_TAG): cv.string,
vol.Exclusive(ATTR_VIBRATE, "silent_xor_vibrate"): vol.All(
cv.ensure_list,
[vol.All(vol.Coerce(int), vol.Range(min=0))],
),
vol.Optional(ATTR_TIMESTAMP): cv.datetime,
vol.Optional(ATTR_LANG): cv.language,
vol.Exclusive(ATTR_SILENT, "silent_xor_vibrate"): cv.boolean,
vol.Optional(ATTR_RENOTIFY): cv.boolean,
vol.Optional(ATTR_REQUIRE_INTERACTION): cv.boolean,
vol.Optional(ATTR_URGENCY): vol.In({"normal", "high", "low"}),
vol.Optional(ATTR_TTL): vol.All(cv.time_period, cv.positive_timedelta),
vol.Optional(ATTR_ACTIONS): vol.All(
cv.ensure_list,
[
{
vol.Required(ATTR_ACTION): cv.string,
vol.Required(ATTR_TITLE): cv.string,
vol.Optional(ATTR_ICON): cv.string,
}
],
),
vol.Optional(ATTR_DATA): dict,
}
)
SERVICE_DISMISS_MESSAGE_SCHEMA = cv.make_entity_service_schema(
{vol.Optional(ATTR_TAG): cv.string}
)
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Set up services for HTML5 integration."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_SEND_MESSAGE,
entity_domain=NOTIFY_DOMAIN,
schema=SERVICE_SEND_MESSAGE_SCHEMA,
func="send_push_notification",
)
service.async_register_platform_entity_service(
hass,
DOMAIN,
SERVICE_DISMISS_MESSAGE,
entity_domain=NOTIFY_DOMAIN,
schema=SERVICE_DISMISS_MESSAGE_SCHEMA,
func="dismiss_notification",
)

View File

@@ -8,144 +8,3 @@ dismiss:
example: '{ "tag": "tagname" }'
selector:
object:
send_message:
target:
entity:
domain: notify
integration: html5
fields:
title:
required: true
selector:
text:
example: Home Assistant
default: Home Assistant
message:
required: false
selector:
text:
multiline: true
example: Hello World
icon:
required: false
selector:
text:
type: url
example: /static/icons/favicon-192x192.png
badge:
required: false
selector:
text:
type: url
example: /static/images/notification-badge.png
image:
required: false
selector:
text:
type: url
example: /static/images/image.jpg
tag: &tag
required: false
selector:
text:
example: message-group-1
actions:
selector:
object:
label_field: "action"
description_field: "title"
multiple: true
translation_key: actions
fields:
action:
required: true
selector:
text:
title:
required: true
selector:
text:
icon:
selector:
text:
type: url
example: '[{"action": "test-action", "title": "🆗 Click here!", "icon": "/images/action-1-128x128.png"}]'
dir:
required: false
selector:
select:
options:
- auto
- ltr
- rtl
mode: dropdown
translation_key: dir
example: auto
renotify:
required: false
selector:
constant:
value: true
label: ""
example: true
silent:
required: false
selector:
constant:
value: true
label: ""
example: true
require_interaction:
required: false
selector:
constant:
value: true
label: ""
example: true
vibrate:
required: false
selector:
text:
multiple: true
type: number
suffix: ms
example: "[125,75,125,275,200,275,125,75,125,275,200,600,200,600]"
lang:
required: false
selector:
language:
example: es-419
timestamp:
required: false
selector:
datetime:
example: "1970-01-01 00:00:00"
ttl:
required: false
selector:
duration:
enable_day: true
example: "{'days': 28}"
urgency:
required: false
selector:
select:
options:
- low
- normal
- high
mode: dropdown
translation_key: urgency
example: normal
data:
required: false
selector:
object:
example: "{'customKey': 'customValue'}"
dismiss_message:
target:
entity:
domain: notify
integration: html5
fields:
tag: *tag

View File

@@ -32,9 +32,7 @@
"received": "Received"
}
},
"tag": {
"name": "[%key:component::html5::services::send_message::fields::tag::name%]"
}
"tag": { "name": "Tag" }
}
}
}
@@ -50,48 +48,6 @@
"message": "Sending notification to {target} failed due to a request error"
}
},
"issues": {
"deprecated_dismiss_action": {
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action}` action instead.",
"title": "[%key:component::html5::issues::deprecated_notify_action::title%]"
},
"deprecated_notify_action": {
"description": "The action `{action}` is deprecated and will be removed in a future release.\n\nPlease update your automations and scripts to use the notify entities with the `{new_action_1}` or `{new_action_2}` actions instead.",
"title": "Detected use of deprecated action {action}"
}
},
"selector": {
"actions": {
"fields": {
"action": {
"description": "The identifier of the action. This will be sent back to Home Assistant when the user clicks the button.",
"name": "Action identifier"
},
"icon": {
"description": "URL of an image displayed as the icon for this button.",
"name": "Icon"
},
"title": {
"description": "The label of the button displayed to the user.",
"name": "Title"
}
}
},
"dir": {
"options": {
"auto": "[%key:common::state::auto%]",
"ltr": "Left-to-right",
"rtl": "Right-to-left"
}
},
"urgency": {
"options": {
"high": "[%key:common::state::high%]",
"low": "[%key:common::state::low%]",
"normal": "[%key:common::state::normal%]"
}
}
},
"services": {
"dismiss": {
"description": "Dismisses an HTML5 notification.",
@@ -106,90 +62,6 @@
}
},
"name": "Dismiss"
},
"dismiss_message": {
"description": "Dismisses one or more HTML5 notifications.",
"fields": {
"tag": {
"description": "The tag of the notifications to dismiss. If not specified, all notifications will be dismissed.",
"name": "[%key:component::html5::services::send_message::fields::tag::name%]"
}
},
"name": "Dismiss message"
},
"send_message": {
"description": "Sends a message via HTML5 Push Notifications",
"fields": {
"actions": {
"description": "Adds action buttons to the notification. When the user clicks a button, an event is sent back to Home Assistant. Amount of actions supported may vary between platforms.",
"name": "Action buttons"
},
"badge": {
"description": "URL or relative path of a small image to replace the browser icon on mobile platforms. Maximum size is 96px by 96px",
"name": "Badge"
},
"data": {
"description": "Additional custom key-value pairs to include in the payload of the push message. This can be used to include extra information that can be accessed in the notification events.",
"name": "Extra data"
},
"dir": {
"description": "The direction of the notification's text. Adopts the browser's language setting behavior by default.",
"name": "Text direction"
},
"icon": {
"description": "URL or relative path of an image to display as the main icon in the notification. Maximum size is 320px by 320px.",
"name": "Icon"
},
"image": {
"description": "URL or relative path of a larger image to display in the main body of the notification. Experimental support, may not be displayed on all platforms.",
"name": "Image"
},
"lang": {
"description": "The language of the notification's content.",
"name": "Language"
},
"message": {
"description": "The message body of the notification.",
"name": "Message"
},
"renotify": {
"description": "If enabled, the user will be alerted again (sound/vibration) when a notification with the same tag replaces a previous one.",
"name": "Renotify"
},
"require_interaction": {
"description": "If enabled, the notification will remain active until the user clicks or dismisses it, rather than automatically closing after a few seconds. This provides the same behavior on desktop as on mobile platforms.",
"name": "Require interaction"
},
"silent": {
"description": "If enabled, the notification will not play sounds or trigger vibration, regardless of the device's notification settings.",
"name": "Silent"
},
"tag": {
"description": "The identifier of the notification. Sending a new notification with the same tag will replace the existing one. If not specified, a unique tag will be generated for each notification.",
"name": "Tag"
},
"timestamp": {
"description": "The timestamp of the notification. By default, it uses the time when the notification is sent.",
"name": "Timestamp"
},
"title": {
"description": "Title for your notification message.",
"name": "Title"
},
"ttl": {
"description": "Specifies how long the push service should retain the message if the user's browser or device is offline. After this period, the notification expires. A value of 0 means the notification is discarded immediately if the target is not connected. Defaults to 1 day.",
"name": "Time to live"
},
"urgency": {
"description": "Whether the push service should try to deliver the notification immediately or defer it in accordance with the user's power saving preferences.",
"name": "Urgency"
},
"vibrate": {
"description": "A vibration pattern to run with the notification. An array of integers representing alternating periods of vibration and silence in milliseconds. For example, [200, 100, 200] would vibrate for 200ms, pause for 100ms, then vibrate for another 200ms.",
"name": "Vibration pattern"
}
},
"name": "Send message"
}
}
}

View File

@@ -1,14 +1,18 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when"
"condition_behavior_description": "How the state should match on the targeted humidifiers.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted humidifiers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_drying": {
"description": "Tests if one or more humidifiers are drying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
}
},
@@ -18,6 +22,7 @@
"description": "Tests if one or more humidifiers are humidifying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
}
},
@@ -27,6 +32,7 @@
"description": "Tests if one or more humidifiers are set to a specific mode.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"mode": {
@@ -40,6 +46,7 @@
"description": "Tests if one or more humidifiers are off.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
}
},
@@ -49,6 +56,7 @@
"description": "Tests if one or more humidifiers are on.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
}
},
@@ -58,9 +66,11 @@
"description": "Tests the target humidity of one or more humidifiers.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::condition_behavior_description%]",
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::humidifier::common::condition_threshold_description%]",
"name": "[%key:component::humidifier::common::condition_threshold_name%]"
}
},
@@ -209,6 +219,7 @@
"description": "Triggers after the operation mode of one or more humidifiers changes.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"mode": {
@@ -222,6 +233,7 @@
"description": "Triggers after one or more humidifiers start drying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
@@ -231,6 +243,7 @@
"description": "Triggers after one or more humidifiers start humidifying.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
@@ -240,6 +253,7 @@
"description": "Triggers after one or more humidifiers turn off.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},
@@ -249,6 +263,7 @@
"description": "Triggers after one or more humidifiers turn on.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
}
},

View File

@@ -1,18 +1,25 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_threshold_name": "Threshold type"
"condition_behavior_description": "How the state should match on the targeted entities.",
"condition_behavior_name": "Behavior",
"condition_threshold_description": "What to test for and threshold values.",
"condition_threshold_name": "Threshold configuration",
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_threshold_changed_description": "Which changes to trigger on and threshold values.",
"trigger_threshold_crossed_description": "Which threshold crossing to trigger on and threshold values.",
"trigger_threshold_name": "Threshold configuration"
},
"conditions": {
"is_value": {
"description": "Tests if a relative humidity value is above a threshold, below a threshold, or in a range of values.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::condition_behavior_description%]",
"name": "[%key:component::humidity::common::condition_behavior_name%]"
},
"threshold": {
"description": "[%key:component::humidity::common::condition_threshold_description%]",
"name": "[%key:component::humidity::common::condition_threshold_name%]"
}
},
@@ -40,6 +47,7 @@
"description": "Triggers after one or more relative humidity values change.",
"fields": {
"threshold": {
"description": "[%key:component::humidity::common::trigger_threshold_changed_description%]",
"name": "[%key:component::humidity::common::trigger_threshold_name%]"
}
},
@@ -49,9 +57,11 @@
"description": "Triggers after one or more relative humidity values cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
"name": "[%key:component::humidity::common::trigger_behavior_name%]"
},
"threshold": {
"description": "[%key:component::humidity::common::trigger_threshold_crossed_description%]",
"name": "[%key:component::humidity::common::trigger_threshold_name%]"
}
},

Some files were not shown because too many files have changed in this diff Show More