Compare commits

..

1 Commits

Author SHA1 Message Date
Petar Petrov
9fc2b6fe43 Allow negative minimum and maximum for random sensor 2025-12-02 08:28:53 +02:00
6830 changed files with 92167 additions and 448064 deletions

View File

@@ -1 +0,0 @@
../.claude/skills/

View File

@@ -1,784 +0,0 @@
---
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
- **Integration code**: `./homeassistant/components/<integration_domain>/`
- **Integration tests**: `./tests/components/<integration_domain>/`
## Integration Templates
### 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
```
An integration can have platforms as needed (e.g., `sensor.py`, `switch.py`, etc.). 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:
### 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
### How Rules Apply
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
2. **Bronze Rules**: Always required for any integration with quality scale
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
- `done`: Rule implemented
- `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
- **Test Scenarios**:
- All flow initiation methods (user, discovery, import)
- Successful configuration paths
- Error recovery scenarios
- Prevention of duplicate entries
- Flow completion after errors
### 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
```

View File

@@ -1,19 +0,0 @@
# Integration Diagnostics
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

@@ -1,55 +0,0 @@
# Repairs platform
Platform exists as `homeassistant/components/<domain>/repairs.py`.
- **Actionable Issues Required**: All repair issues must be actionable for end users
- **Issue Content Requirements**:
- Clearly explain what is happening
- Provide specific steps users need to take to resolve the issue
- Use friendly, helpful language
- Include relevant context (device names, error details, etc.)
- **Implementation**:
```python
ir.async_create_issue(
hass,
DOMAIN,
"outdated_version",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.ERROR,
translation_key="outdated_version",
)
```
- **Translation Strings Requirements**: Must contain user-actionable text in `strings.json`:
```json
{
"issues": {
"outdated_version": {
"title": "Device firmware is outdated",
"description": "Your device firmware version {current_version} is below the minimum required version {min_version}. To fix this issue: 1) Open the manufacturer's mobile app, 2) Navigate to device settings, 3) Select 'Update Firmware', 4) Wait for the update to complete, then 5) Restart Home Assistant."
}
}
}
```
- **String Content Must Include**:
- What the problem is
- Why it matters
- Exact steps to resolve (numbered list when multiple steps)
- What to expect after following the steps
- **Avoid Vague Instructions**: Don't just say "update firmware" - provide specific steps
- **Severity Guidelines**:
- `CRITICAL`: Reserved for extreme scenarios only
- `ERROR`: Requires immediate user attention
- `WARNING`: Indicates future potential breakage
- **Additional Attributes**:
```python
ir.async_create_issue(
hass, DOMAIN, "issue_id",
breaks_in_ha_version="2024.1.0",
is_fixable=True,
is_persistent=True,
severity=ir.IssueSeverity.ERROR,
translation_key="issue_description",
)
```
- Only create issues for problems users can potentially resolve

View File

@@ -13,7 +13,6 @@ core: &core
# Our base platforms, that are used by other integrations # Our base platforms, that are used by other integrations
base_platforms: &base_platforms base_platforms: &base_platforms
- homeassistant/components/ai_task/**
- homeassistant/components/air_quality/** - homeassistant/components/air_quality/**
- homeassistant/components/alarm_control_panel/** - homeassistant/components/alarm_control_panel/**
- homeassistant/components/assist_satellite/** - homeassistant/components/assist_satellite/**
@@ -22,7 +21,6 @@ base_platforms: &base_platforms
- homeassistant/components/calendar/** - homeassistant/components/calendar/**
- homeassistant/components/camera/** - homeassistant/components/camera/**
- homeassistant/components/climate/** - homeassistant/components/climate/**
- homeassistant/components/conversation/**
- homeassistant/components/cover/** - homeassistant/components/cover/**
- homeassistant/components/date/** - homeassistant/components/date/**
- homeassistant/components/datetime/** - homeassistant/components/datetime/**
@@ -34,7 +32,6 @@ base_platforms: &base_platforms
- homeassistant/components/humidifier/** - homeassistant/components/humidifier/**
- homeassistant/components/image/** - homeassistant/components/image/**
- homeassistant/components/image_processing/** - homeassistant/components/image_processing/**
- homeassistant/components/infrared/**
- homeassistant/components/lawn_mower/** - homeassistant/components/lawn_mower/**
- homeassistant/components/light/** - homeassistant/components/light/**
- homeassistant/components/lock/** - homeassistant/components/lock/**
@@ -55,7 +52,6 @@ base_platforms: &base_platforms
- homeassistant/components/update/** - homeassistant/components/update/**
- homeassistant/components/vacuum/** - homeassistant/components/vacuum/**
- homeassistant/components/valve/** - homeassistant/components/valve/**
- homeassistant/components/wake_word/**
- homeassistant/components/water_heater/** - homeassistant/components/water_heater/**
- homeassistant/components/weather/** - homeassistant/components/weather/**
@@ -73,6 +69,7 @@ components: &components
- homeassistant/components/cloud/** - homeassistant/components/cloud/**
- homeassistant/components/config/** - homeassistant/components/config/**
- homeassistant/components/configurator/** - homeassistant/components/configurator/**
- homeassistant/components/conversation/**
- homeassistant/components/demo/** - homeassistant/components/demo/**
- homeassistant/components/device_automation/** - homeassistant/components/device_automation/**
- homeassistant/components/dhcp/** - homeassistant/components/dhcp/**
@@ -93,7 +90,6 @@ components: &components
- homeassistant/components/input_number/** - homeassistant/components/input_number/**
- homeassistant/components/input_select/** - homeassistant/components/input_select/**
- homeassistant/components/input_text/** - homeassistant/components/input_text/**
- homeassistant/components/labs/**
- homeassistant/components/logbook/** - homeassistant/components/logbook/**
- homeassistant/components/logger/** - homeassistant/components/logger/**
- homeassistant/components/lovelace/** - homeassistant/components/lovelace/**

View File

@@ -8,6 +8,9 @@
"PYTHONASYNCIODEBUG": "1" "PYTHONASYNCIODEBUG": "1"
}, },
"features": { "features": {
// Node feature required for Claude Code until fixed https://github.com/anthropics/devcontainer-features/issues/28
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
"ghcr.io/devcontainers/features/github-cli:1": {} "ghcr.io/devcontainers/features/github-cli:1": {}
}, },
// Port 5683 udp is used by Shelly integration // Port 5683 udp is used by Shelly integration
@@ -24,6 +27,7 @@
"charliermarsh.ruff", "charliermarsh.ruff",
"ms-python.pylint", "ms-python.pylint",
"ms-python.vscode-pylance", "ms-python.vscode-pylance",
"visualstudioexptteam.vscodeintellicode",
"redhat.vscode-yaml", "redhat.vscode-yaml",
"esbenp.prettier-vscode", "esbenp.prettier-vscode",
"GitHub.vscode-pull-request-github", "GitHub.vscode-pull-request-github",
@@ -37,8 +41,7 @@
"python.terminal.activateEnvInCurrentTerminal": true, "python.terminal.activateEnvInCurrentTerminal": true,
"python.testing.pytestArgs": ["--no-cov"], "python.testing.pytestArgs": ["--no-cov"],
"pylint.importStrategy": "fromEnvironment", "pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking "python.analysis.typeCheckingMode": "basic",
"python.analysis.typeCheckingMode": "off",
"editor.formatOnPaste": false, "editor.formatOnPaste": false,
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.formatOnType": true, "editor.formatOnType": true,
@@ -60,13 +63,7 @@
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff" "editor.defaultFormatter": "charliermarsh.ruff"
}, },
"[json]": { "[json][jsonc][yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[yaml]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"json.schemas": [ "json.schemas": [

View File

@@ -1 +0,0 @@
../.claude/skills

View File

@@ -80,7 +80,7 @@ If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running: `python3 -m script.hassfest`. Updated and included derived files by running: `python3 -m script.hassfest`.
- [ ] New or updated dependencies have been added to `requirements_all.txt`. - [ ] New or updated dependencies have been added to `requirements_all.txt`.
Updated by running `python3 -m script.gen_requirements_all`. Updated by running `python3 -m script.gen_requirements_all`.
- [ ] For the updated dependencies a diff between library versions and ideally a link to the changelog/release notes is added to the PR description. - [ ] For the updated dependencies - a link to the changelog, or at minimum a diff between library versions is added to the PR description.
<!-- <!--
This project is very active and we have a high turnover of pull requests. This project is very active and we have a high turnover of pull requests.

File diff suppressed because it is too large Load Diff

View File

@@ -9,5 +9,3 @@ updates:
labels: labels:
- dependency - dependency
- github_actions - github_actions
cooldown:
default-days: 7

View File

@@ -10,27 +10,19 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.14.2" DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60 PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"
# Base image version from https://github.com/home-assistant/docker # Base image version from https://github.com/home-assistant/docker
BASE_IMAGE_VERSION: "2026.01.0" BASE_IMAGE_VERSION: "2025.11.3"
ARCHITECTURES: '["amd64", "aarch64"]' ARCHITECTURES: '["amd64", "aarch64"]'
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
init: init:
name: Initialize build name: Initialize build
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read # To check out the repository
outputs: outputs:
version: ${{ steps.version.outputs.version }} version: ${{ steps.version.outputs.version }}
channel: ${{ steps.version.outputs.channel }} channel: ${{ steps.version.outputs.channel }}
@@ -38,27 +30,25 @@ jobs:
architectures: ${{ env.ARCHITECTURES }} architectures: ${{ env.ARCHITECTURES }}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Get information - name: Get information
id: info id: info
uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/info@master
- name: Get version - name: Get version
id: version id: version
uses: home-assistant/actions/helpers/version@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/version@master
with: with:
type: ${{ env.BUILD_TYPE }} type: ${{ env.BUILD_TYPE }}
- name: Verify version - name: Verify version
uses: home-assistant/actions/helpers/verify-version@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/verify-version@master
with: with:
ignore-dev: true ignore-dev: true
@@ -80,7 +70,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz
@@ -92,9 +82,9 @@ jobs:
needs: init needs: init
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
permissions: permissions:
contents: read # To check out the repository contents: read
packages: write # To push to GHCR packages: write
id-token: write # For cosign signing id-token: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -106,13 +96,11 @@ jobs:
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Download nightly wheels of frontend - name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend repo: home-assistant/frontend
@@ -123,7 +111,7 @@ jobs:
- name: Download nightly wheels of intents - name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: dawidd6/action-download-artifact@5c98f0b039f36ef966fdb7dfa9779262785ecb05 # v14 uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with: with:
github_token: ${{secrets.GITHUB_TOKEN}} github_token: ${{secrets.GITHUB_TOKEN}}
repo: OHF-Voice/intents-package repo: OHF-Voice/intents-package
@@ -134,7 +122,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev' if: needs.init.outputs.channel == 'dev'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
@@ -143,12 +131,11 @@ jobs:
shell: bash shell: bash
env: env:
UV_PRERELEASE: allow UV_PRERELEASE: allow
VERSION: ${{ needs.init.outputs.version }}
run: | run: |
python3 -m pip install "$(grep '^uv' < requirements.txt)" python3 -m pip install "$(grep '^uv' < requirements.txt)"
uv pip install packaging tomli uv pip install packaging tomli
uv pip install . uv pip install .
python3 script/version_bump.py nightly --set-nightly-version "${VERSION}" python3 script/version_bump.py nightly --set-nightly-version "${{ needs.init.outputs.version }}"
if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then
echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}" echo "Found frontend wheel, setting version to: ${BASH_REMATCH[1]}"
@@ -178,11 +165,11 @@ jobs:
sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \ sed -i "s|home-assistant-intents==.*|home-assistant-intents==${BASH_REMATCH[1]}|" \
homeassistant/package_constraints.txt homeassistant/package_constraints.txt
sed -i "s|home-assistant-intents==.*||" requirements_all.txt requirements.txt sed -i "s|home-assistant-intents==.*||" requirements_all.txt
fi fi
- name: Download translations - name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: translations name: translations
@@ -194,56 +181,51 @@ jobs:
- name: Write meta info file - name: Write meta info file
shell: bash shell: bash
run: | run: |
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install Cosign - &install_cosign
name: Install Cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with: with:
cosign-release: "v2.5.3" cosign-release: "v2.5.3"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Build variables - name: Build variables
id: vars id: vars
shell: bash shell: bash
env:
ARCH: ${{ matrix.arch }}
run: | run: |
echo "base_image=ghcr.io/home-assistant/${ARCH}-homeassistant-base:${BASE_IMAGE_VERSION}" >> "$GITHUB_OUTPUT" echo "base_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant-base:${{ env.BASE_IMAGE_VERSION }}" >> "$GITHUB_OUTPUT"
echo "cache_image=ghcr.io/home-assistant/${ARCH}-homeassistant:latest" >> "$GITHUB_OUTPUT" echo "cache_image=ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:latest" >> "$GITHUB_OUTPUT"
echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT" echo "created=$(date --rfc-3339=seconds --utc)" >> "$GITHUB_OUTPUT"
- name: Verify base image signature - name: Verify base image signature
env:
BASE_IMAGE: ${{ steps.vars.outputs.base_image }}
run: | run: |
cosign verify \ cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \ --certificate-identity-regexp "https://github.com/home-assistant/docker/.*" \
"${BASE_IMAGE}" "${{ steps.vars.outputs.base_image }}"
- name: Verify cache image signature - name: Verify cache image signature
id: cache id: cache
continue-on-error: true continue-on-error: true
env:
CACHE_IMAGE: ${{ steps.vars.outputs.cache_image }}
run: | run: |
cosign verify \ cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "https://github.com/home-assistant/core/.*" \ --certificate-identity-regexp "https://github.com/home-assistant/core/.*" \
"${CACHE_IMAGE}" "${{ steps.vars.outputs.cache_image }}"
- name: Build base image - name: Build base image
id: build id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -253,7 +235,6 @@ jobs:
build-args: | build-args: |
BUILD_FROM=${{ steps.vars.outputs.base_image }} BUILD_FROM=${{ steps.vars.outputs.base_image }}
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }} tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
labels: | labels: |
io.hass.arch=${{ matrix.arch }} io.hass.arch=${{ matrix.arch }}
io.hass.version=${{ needs.init.outputs.version }} io.hass.version=${{ needs.init.outputs.version }}
@@ -261,22 +242,18 @@ jobs:
org.opencontainers.image.version=${{ needs.init.outputs.version }} org.opencontainers.image.version=${{ needs.init.outputs.version }}
- name: Sign image - name: Sign image
env:
ARCH: ${{ matrix.arch }}
VERSION: ${{ needs.init.outputs.version }}
DIGEST: ${{ steps.build.outputs.digest }}
run: | run: |
cosign sign --yes "ghcr.io/home-assistant/${ARCH}-homeassistant:${VERSION}@${DIGEST}" cosign sign --yes "ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}@${{ steps.build.outputs.digest }}"
build_machine: build_machine:
name: Build ${{ matrix.machine }} machine core image name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"] needs: ["init", "build_base"]
runs-on: ${{ matrix.runs-on }} runs-on: ubuntu-latest
permissions: permissions:
contents: read # To check out the repository contents: read
packages: write # To push to GHCR packages: write
id-token: write # For cosign signing id-token: write
strategy: strategy:
matrix: matrix:
machine: machine:
@@ -294,51 +271,32 @@ jobs:
- raspberrypi5-64 - raspberrypi5-64
- yellow - yellow
- green - green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Set build additional args - name: Set build additional args
env:
VERSION: ${{ needs.init.outputs.version }}
run: | run: |
# Create general tags # Create general tags
if [[ "${VERSION}" =~ d ]]; then if [[ "${{ needs.init.outputs.version }}" =~ d ]]; then
echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV echo "BUILD_ARGS=--additional-tag dev" >> $GITHUB_ENV
elif [[ "${VERSION}" =~ b ]]; then elif [[ "${{ needs.init.outputs.version }}" =~ b ]]; then
echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV echo "BUILD_ARGS=--additional-tag beta" >> $GITHUB_ENV
else else
echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV echo "BUILD_ARGS=--additional-tag stable" >> $GITHUB_ENV
fi fi
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# home-assistant/builder doesn't support sha pinning
- name: Build base image - name: Build base image
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1 uses: home-assistant/builder@2025.11.0
with: with:
image: ${{ matrix.arch }}
args: | args: |
$BUILD_ARGS \ $BUILD_ARGS \
--target /data/machine \ --target /data/machine \
@@ -351,23 +309,19 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
needs: ["init", "build_machine"] needs: ["init", "build_machine"]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Initialize git - name: Initialize git
uses: home-assistant/actions/helpers/git-init@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/git-init@master
with: with:
name: ${{ secrets.GIT_NAME }} name: ${{ secrets.GIT_NAME }}
email: ${{ secrets.GIT_EMAIL }} email: ${{ secrets.GIT_EMAIL }}
token: ${{ secrets.GIT_TOKEN }} token: ${{ secrets.GIT_TOKEN }}
- name: Update version file - name: Update version file
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/version-push@master
with: with:
key: "homeassistant[]" key: "homeassistant[]"
key-description: "Home Assistant Core" key-description: "Home Assistant Core"
@@ -377,7 +331,7 @@ jobs:
- name: Update version file (stable -> beta) - name: Update version file (stable -> beta)
if: needs.init.outputs.channel == 'stable' if: needs.init.outputs.channel == 'stable'
uses: home-assistant/actions/helpers/version-push@master # zizmor: ignore[unpinned-uses] uses: home-assistant/actions/helpers/version-push@master
with: with:
key: "homeassistant[]" key: "homeassistant[]"
key-description: "Home Assistant Core" key-description: "Home Assistant Core"
@@ -392,28 +346,25 @@ jobs:
needs: ["init", "build_base"] needs: ["init", "build_base"]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read # To check out the repository contents: read
packages: write # To push to GHCR packages: write
id-token: write # For cosign signing id-token: write
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"] registry: ["ghcr.io/home-assistant", "docker.io/homeassistant"]
steps: steps:
- name: Install Cosign - *install_cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.5.3"
- name: Login to DockerHub - name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
@@ -421,17 +372,14 @@ jobs:
- name: Verify architecture image signatures - name: Verify architecture image signatures
shell: bash shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: | run: |
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]') ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do for arch in $ARCHS; do
echo "Verifying ${arch} image signature..." echo "Verifying ${arch} image signature..."
cosign verify \ cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp https://github.com/home-assistant/core/.* \ --certificate-identity-regexp https://github.com/home-assistant/core/.* \
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}" "ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
done done
echo "✓ All images verified successfully" echo "✓ All images verified successfully"
@@ -457,55 +405,37 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }} type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1 uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.7.1
- name: Copy architecture images to DockerHub - name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant' if: matrix.registry == 'docker.io/homeassistant'
shell: bash shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
VERSION: ${{ needs.init.outputs.version }}
run: | run: |
# Use imagetools to copy image blobs directly between registries # Use imagetools to copy image blobs directly between registries
# This preserves provenance/attestations and seems to be much faster than pull/push # This preserves provenance/attestations and seems to be much faster than pull/push
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]') ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
for arch in $ARCHS; do for arch in $ARCHS; do
echo "Copying ${arch} image to DockerHub..." echo "Copying ${arch} image to DockerHub..."
for attempt in 1 2 3; do docker buildx imagetools create \
if docker buildx imagetools create \ --tag "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}" \
--tag "docker.io/homeassistant/${arch}-homeassistant:${VERSION}" \ "ghcr.io/home-assistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
"ghcr.io/home-assistant/${arch}-homeassistant:${VERSION}"; then cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${{ needs.init.outputs.version }}"
break
fi
echo "Attempt ${attempt} failed, retrying in 10 seconds..."
sleep 10
if [ "${attempt}" -eq 3 ]; then
echo "Failed after 3 attempts"
exit 1
fi
done
cosign sign --yes "docker.io/homeassistant/${arch}-homeassistant:${VERSION}"
done done
- name: Create and push multi-arch manifests - name: Create and push multi-arch manifests
shell: bash shell: bash
env:
ARCHITECTURES: ${{ needs.init.outputs.architectures }}
REGISTRY: ${{ matrix.registry }}
VERSION: ${{ needs.init.outputs.version }}
META_TAGS: ${{ steps.meta.outputs.tags }}
run: | run: |
# Build list of architecture images dynamically # Build list of architecture images dynamically
ARCHS=$(echo "${ARCHITECTURES}" | jq -r '.[]') ARCHS=$(echo '${{ needs.init.outputs.architectures }}' | jq -r '.[]')
ARCH_IMAGES=() ARCH_IMAGES=()
for arch in $ARCHS; do for arch in $ARCHS; do
ARCH_IMAGES+=("${REGISTRY}/${arch}-homeassistant:${VERSION}") ARCH_IMAGES+=("${{ matrix.registry }}/${arch}-homeassistant:${{ needs.init.outputs.version }}")
done done
# Build list of all tags for single manifest creation # Build list of all tags for single manifest creation
# Note: Using sep-tags=',' in metadata-action for easier parsing # Note: Using sep-tags=',' in metadata-action for easier parsing
TAG_ARGS=() TAG_ARGS=()
IFS=',' read -ra TAGS <<< "${META_TAGS}" IFS=',' read -ra TAGS <<< "${{ steps.meta.outputs.tags }}"
for tag in "${TAGS[@]}"; do for tag in "${TAGS[@]}"; do
TAG_ARGS+=("--tag" "${tag}") TAG_ARGS+=("--tag" "${tag}")
done done
@@ -529,22 +459,20 @@ jobs:
needs: ["init", "build_base"] needs: ["init", "build_base"]
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read # To check out the repository contents: read
id-token: write # For PyPI trusted publishing id-token: write
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations - name: Download translations
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: translations name: translations
@@ -570,10 +498,10 @@ jobs:
name: Build and test hassfest image name: Build and test hassfest image
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read # To check out the repository contents: read
packages: write # To push to GHCR packages: write
attestations: write # For build provenance attestation attestations: write
id-token: write # For build provenance attestation id-token: write
needs: ["init"] needs: ["init"]
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
env: env:
@@ -581,19 +509,17 @@ jobs:
HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }} HASSFEST_IMAGE_TAG: ghcr.io/home-assistant/hassfest:${{ needs.init.outputs.version }}
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image - name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . # So action will not pull the repository again context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile file: ./script/hassfest/docker/Dockerfile
@@ -601,12 +527,12 @@ jobs:
tags: ${{ env.HASSFEST_IMAGE_TAG }} tags: ${{ env.HASSFEST_IMAGE_TAG }}
- name: Run hassfest against core - name: Run hassfest against core
run: docker run --rm -v "${GITHUB_WORKSPACE}":/github/workspace "${HASSFEST_IMAGE_TAG}" --core-path=/github/workspace run: docker run --rm -v ${{ github.workspace }}:/github/workspace ${{ env.HASSFEST_IMAGE_TAG }} --core-path=/github/workspace
- name: Push Docker image - name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push id: push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with: with:
context: . # So action will not pull the repository again context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile file: ./script/hassfest/docker/Dockerfile
@@ -615,7 +541,7 @@ jobs:
- name: Generate artifact attestation - name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true' if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3.0.0
with: with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }} subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }} subject-digest: ${{ steps.push.outputs.digest }}

File diff suppressed because it is too large Load Diff

View File

@@ -5,8 +5,6 @@ on:
schedule: schedule:
- cron: "30 18 * * 4" - cron: "30 18 * * 4"
permissions: {}
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
@@ -17,22 +15,20 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 360 timeout-minutes: 360
permissions: permissions:
actions: read # To read workflow information for CodeQL actions: read
contents: read # To check out the repository contents: read
security-events: write # To upload CodeQL results security-events: write
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e907b5e64f6b83e7804b09294d44122997950d6 # v4.32.3 uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -5,18 +5,13 @@ on:
issues: issues:
types: [labeled] types: [labeled]
permissions: {} permissions:
issues: write
concurrency: models: read
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs: jobs:
detect-duplicates: detect-duplicates:
name: Detect duplicate issues
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write # To comment on and label issues
models: read # For AI-based duplicate detection
steps: steps:
- name: Check if integration label was added and extract details - name: Check if integration label was added and extract details
@@ -236,7 +231,7 @@ jobs:
- name: Detect duplicates using AI - name: Detect duplicates using AI
id: ai_detection id: ai_detection
if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6 uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with: with:
model: openai/gpt-4o model: openai/gpt-4o
system-prompt: | system-prompt: |

View File

@@ -5,18 +5,13 @@ on:
issues: issues:
types: [opened] types: [opened]
permissions: {} permissions:
issues: write
concurrency: models: read
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs: jobs:
detect-language: detect-language:
name: Detect non-English issues
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write # To comment on, label, and close issues
models: read # For AI-based language detection
steps: steps:
- name: Check issue language - name: Check issue language
@@ -62,7 +57,7 @@ jobs:
- name: Detect language using AI - name: Detect language using AI
id: ai_language_detection id: ai_language_detection
if: steps.detect_language.outputs.should_continue == 'true' if: steps.detect_language.outputs.should_continue == 'true'
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6 uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with: with:
model: openai/gpt-4o-mini model: openai/gpt-4o-mini
system-prompt: | system-prompt: |

View File

@@ -5,22 +5,12 @@ on:
schedule: schedule:
- cron: "0 * * * *" - cron: "0 * * * *"
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs: jobs:
lock: lock:
name: Lock inactive threads
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write # To lock issues
pull-requests: write # To lock pull requests
steps: steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0 - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
with: with:
github-token: ${{ github.token }} github-token: ${{ github.token }}
issue-inactive-days: "30" issue-inactive-days: "30"

View File

@@ -4,7 +4,7 @@
"owner": "check-executables-have-shebangs", "owner": "check-executables-have-shebangs",
"pattern": [ "pattern": [
{ {
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$", "regexp": "^(.+):\\s(.+)$",
"file": 1, "file": 1,
"message": 2 "message": 2
} }

View File

@@ -5,39 +5,9 @@ on:
issues: issues:
types: [opened] types: [opened]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}
jobs: jobs:
add-no-stale:
name: Add no-stale label
runs-on: ubuntu-latest
permissions:
issues: write # To add labels to issues
if: >-
github.event.issue.type.name == 'Task'
|| github.event.issue.type.name == 'Epic'
|| github.event.issue.type.name == 'Opportunity'
steps:
- name: Add no-stale label
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['no-stale']
});
check-authorization: check-authorization:
name: Check authorization
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read # To read CODEOWNERS file
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form) # Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task' if: github.event.issue.type.name == 'Task'
steps: steps:

View File

@@ -6,20 +6,10 @@ on:
- cron: "0 * * * *" - cron: "0 * * * *"
workflow_dispatch: workflow_dispatch:
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs: jobs:
stale: stale:
name: Mark stale issues and PRs
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write # To label and close stale issues
pull-requests: write # To label and close stale PRs
steps: steps:
# The 60 day stale policy for PRs # The 60 day stale policy for PRs
# Used for: # Used for:
@@ -27,7 +17,7 @@ jobs:
# - No PRs marked as no-stale # - No PRs marked as no-stale
# - No issues (-1) # - No issues (-1)
- name: 60 days stale PRs policy - name: 60 days stale PRs policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 60 days-before-stale: 60
@@ -67,7 +57,7 @@ jobs:
# - No issues marked as no-stale or help-wanted # - No issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: 90 days stale issues - name: 90 days stale issues
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
days-before-stale: 90 days-before-stale: 90
@@ -97,7 +87,7 @@ jobs:
# - No Issues marked as no-stale or help-wanted # - No Issues marked as no-stale or help-wanted
# - No PRs (-1) # - No PRs (-1)
- name: Needs more information stale issues policy - name: Needs more information stale issues policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with: with:
repo-token: ${{ steps.token.outputs.token }} repo-token: ${{ steps.token.outputs.token }}
only-labels: "needs-more-information" only-labels: "needs-more-information"

View File

@@ -9,14 +9,8 @@ on:
paths: paths:
- "**strings.json" - "**strings.json"
permissions: {}
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env: env:
DEFAULT_PYTHON: "3.14.2" DEFAULT_PYTHON: "3.13"
jobs: jobs:
upload: upload:
@@ -25,17 +19,14 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Upload Translations - name: Upload Translations
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
run: | run: |
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
python3 -m script.translations upload python3 -m script.translations upload

View File

@@ -17,9 +17,7 @@ on:
- "script/gen_requirements_all.py" - "script/gen_requirements_all.py"
env: env:
DEFAULT_PYTHON: "3.14.2" DEFAULT_PYTHON: "3.13"
permissions: {}
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref_name}} group: ${{ github.workflow }}-${{ github.ref_name}}
@@ -31,14 +29,13 @@ jobs:
if: github.repository_owner == 'home-assistant' if: github.repository_owner == 'home-assistant'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - &checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 name: Checkout the repository
with: uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
persist-credentials: false
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true check-latest: true
@@ -53,7 +50,7 @@ jobs:
- name: Create requirements_diff file - name: Create requirements_diff file
run: | run: |
if [[ "${GITHUB_EVENT_NAME}" =~ (schedule|workflow_dispatch) ]]; then if [[ ${{ github.event_name }} =~ (schedule|workflow_dispatch) ]]; then
touch requirements_diff.txt touch requirements_diff.txt
else else
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements.txt
@@ -77,7 +74,7 @@ jobs:
) > .env_file ) > .env_file
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
@@ -85,7 +82,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: *actions-upload-artifact
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
@@ -97,7 +94,7 @@ jobs:
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels - name: Upload requirements_all_wheels
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 uses: *actions-upload-artifact
with: with:
name: requirements_all_wheels name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt path: ./requirements_all_wheels_*.txt
@@ -109,8 +106,8 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix: &matrix-build
abi: ["cp314"] abi: ["cp313", "cp314"]
arch: ["amd64", "aarch64"] arch: ["amd64", "aarch64"]
include: include:
- arch: amd64 - arch: amd64
@@ -118,18 +115,17 @@ jobs:
- arch: aarch64 - arch: aarch64
os: ubuntu-24.04-arm os: ubuntu-24.04-arm
steps: steps:
- name: Checkout the repository - *checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download env_file - &download-env-file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 name: Download env_file
uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: env_file name: env_file
- name: Download requirements_diff - &download-requirements-diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 name: Download requirements_diff
uses: *actions-download-artifact
with: with:
name: requirements_diff name: requirements_diff
@@ -140,7 +136,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0 uses: &home-assistant-wheels home-assistant/wheels@6066c17a2a4aafcf7bdfeae01717f63adfcdba98 # 2025.11.0
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2
@@ -160,32 +156,16 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix: *matrix-build
abi: ["cp314"]
arch: ["amd64", "aarch64"]
include:
- arch: amd64
os: ubuntu-latest
- arch: aarch64
os: ubuntu-24.04-arm
steps: steps:
- name: Checkout the repository - *checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download env_file - *download-env-file
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: env_file
- name: Download requirements_diff - *download-requirements-diff
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
with:
name: requirements_diff
- name: Download requirements_all_wheels - name: Download requirements_all_wheels
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 uses: *actions-download-artifact
with: with:
name: requirements_all_wheels name: requirements_all_wheels
@@ -198,7 +178,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt sed -i "/uv/d" requirements_diff.txt
- name: Build wheels - name: Build wheels
uses: home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0 uses: *home-assistant-wheels
with: with:
abi: ${{ matrix.abi }} abi: ${{ matrix.abi }}
tag: musllinux_1_2 tag: musllinux_1_2

1
.gitignore vendored
View File

@@ -92,7 +92,6 @@ pip-selfcheck.json
venv venv
.venv .venv
Pipfile* Pipfile*
uv.lock
share/* share/*
/Scripts/ /Scripts/

View File

@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.1 rev: v0.13.0
hooks: hooks:
- id: ruff-check - id: ruff-check
args: args:
@@ -17,12 +17,6 @@ repos:
- --quiet-level=2 - --quiet-level=2
exclude_types: [csv, json, html] exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args:
- --pedantic
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0 rev: v6.0.0
hooks: hooks:
@@ -45,14 +39,14 @@ repos:
- id: prettier - id: prettier
additional_dependencies: additional_dependencies:
- prettier@3.6.2 - prettier@3.6.2
- prettier-plugin-sort-json@4.2.0 - prettier-plugin-sort-json@4.1.1
- repo: https://github.com/cdce8p/python-typing-update - repo: https://github.com/cdce8p/python-typing-update
rev: v0.6.0 rev: v0.6.0
hooks: hooks:
# Run `python-typing-update` hook manually from time to time # Run `python-typing-update` hook manually from time to time
# to update python typing syntax. # to update python typing syntax.
# Will require manual work, before submitting changes! # Will require manual work, before submitting changes!
# prek run --hook-stage manual python-typing-update --all-files # pre-commit run --hook-stage manual python-typing-update --all-files
- id: python-typing-update - id: python-typing-update
stages: [manual] stages: [manual]
args: args:

View File

@@ -1 +1 @@
3.14 3.13

View File

@@ -49,12 +49,10 @@ homeassistant.components.actiontec.*
homeassistant.components.adax.* homeassistant.components.adax.*
homeassistant.components.adguard.* homeassistant.components.adguard.*
homeassistant.components.aftership.* homeassistant.components.aftership.*
homeassistant.components.ai_task.*
homeassistant.components.air_quality.* homeassistant.components.air_quality.*
homeassistant.components.airgradient.* homeassistant.components.airgradient.*
homeassistant.components.airly.* homeassistant.components.airly.*
homeassistant.components.airnow.* homeassistant.components.airnow.*
homeassistant.components.airobot.*
homeassistant.components.airos.* homeassistant.components.airos.*
homeassistant.components.airq.* homeassistant.components.airq.*
homeassistant.components.airthings.* homeassistant.components.airthings.*
@@ -85,7 +83,6 @@ homeassistant.components.androidtv_remote.*
homeassistant.components.anel_pwrctrl.* homeassistant.components.anel_pwrctrl.*
homeassistant.components.anova.* homeassistant.components.anova.*
homeassistant.components.anthemav.* homeassistant.components.anthemav.*
homeassistant.components.anthropic.*
homeassistant.components.apache_kafka.* homeassistant.components.apache_kafka.*
homeassistant.components.apcupsd.* homeassistant.components.apcupsd.*
homeassistant.components.api.* homeassistant.components.api.*
@@ -131,7 +128,6 @@ homeassistant.components.bring.*
homeassistant.components.brother.* homeassistant.components.brother.*
homeassistant.components.browser.* homeassistant.components.browser.*
homeassistant.components.bryant_evolution.* homeassistant.components.bryant_evolution.*
homeassistant.components.bsblan.*
homeassistant.components.bthome.* homeassistant.components.bthome.*
homeassistant.components.button.* homeassistant.components.button.*
homeassistant.components.calendar.* homeassistant.components.calendar.*
@@ -211,7 +207,6 @@ homeassistant.components.firefly_iii.*
homeassistant.components.fitbit.* homeassistant.components.fitbit.*
homeassistant.components.flexit_bacnet.* homeassistant.components.flexit_bacnet.*
homeassistant.components.flux_led.* homeassistant.components.flux_led.*
homeassistant.components.folder_watcher.*
homeassistant.components.forecast_solar.* homeassistant.components.forecast_solar.*
homeassistant.components.fritz.* homeassistant.components.fritz.*
homeassistant.components.fritzbox.* homeassistant.components.fritzbox.*
@@ -225,7 +220,6 @@ homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.* homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.* homeassistant.components.geo_location.*
homeassistant.components.geocaching.* homeassistant.components.geocaching.*
homeassistant.components.ghost.*
homeassistant.components.gios.* homeassistant.components.gios.*
homeassistant.components.github.* homeassistant.components.github.*
homeassistant.components.glances.* homeassistant.components.glances.*
@@ -246,7 +240,6 @@ homeassistant.components.guardian.*
homeassistant.components.habitica.* homeassistant.components.habitica.*
homeassistant.components.hardkernel.* homeassistant.components.hardkernel.*
homeassistant.components.hardware.* homeassistant.components.hardware.*
homeassistant.components.hdfury.*
homeassistant.components.heos.* homeassistant.components.heos.*
homeassistant.components.here_travel_time.* homeassistant.components.here_travel_time.*
homeassistant.components.history.* homeassistant.components.history.*
@@ -278,7 +271,6 @@ homeassistant.components.humidifier.*
homeassistant.components.husqvarna_automower.* homeassistant.components.husqvarna_automower.*
homeassistant.components.hydrawise.* homeassistant.components.hydrawise.*
homeassistant.components.hyperion.* homeassistant.components.hyperion.*
homeassistant.components.hypontech.*
homeassistant.components.ibeacon.* homeassistant.components.ibeacon.*
homeassistant.components.idasen_desk.* homeassistant.components.idasen_desk.*
homeassistant.components.image.* homeassistant.components.image.*
@@ -289,12 +281,10 @@ homeassistant.components.imgw_pib.*
homeassistant.components.immich.* homeassistant.components.immich.*
homeassistant.components.incomfort.* homeassistant.components.incomfort.*
homeassistant.components.inels.* homeassistant.components.inels.*
homeassistant.components.infrared.*
homeassistant.components.input_button.* homeassistant.components.input_button.*
homeassistant.components.input_select.* homeassistant.components.input_select.*
homeassistant.components.input_text.* homeassistant.components.input_text.*
homeassistant.components.integration.* homeassistant.components.integration.*
homeassistant.components.intelliclima.*
homeassistant.components.intent.* homeassistant.components.intent.*
homeassistant.components.intent_script.* homeassistant.components.intent_script.*
homeassistant.components.ios.* homeassistant.components.ios.*
@@ -302,7 +292,6 @@ homeassistant.components.iotty.*
homeassistant.components.ipp.* homeassistant.components.ipp.*
homeassistant.components.iqvia.* homeassistant.components.iqvia.*
homeassistant.components.iron_os.* homeassistant.components.iron_os.*
homeassistant.components.isal.*
homeassistant.components.islamic_prayer_times.* homeassistant.components.islamic_prayer_times.*
homeassistant.components.isy994.* homeassistant.components.isy994.*
homeassistant.components.jellyfin.* homeassistant.components.jellyfin.*
@@ -313,7 +302,6 @@ homeassistant.components.knocki.*
homeassistant.components.knx.* homeassistant.components.knx.*
homeassistant.components.kraken.* homeassistant.components.kraken.*
homeassistant.components.kulersky.* homeassistant.components.kulersky.*
homeassistant.components.labs.*
homeassistant.components.lacrosse.* homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.* homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.* homeassistant.components.lamarzocco.*
@@ -373,7 +361,7 @@ homeassistant.components.my.*
homeassistant.components.mysensors.* homeassistant.components.mysensors.*
homeassistant.components.myuplink.* homeassistant.components.myuplink.*
homeassistant.components.nam.* homeassistant.components.nam.*
homeassistant.components.namecheapdns.* homeassistant.components.nanoleaf.*
homeassistant.components.nasweb.* homeassistant.components.nasweb.*
homeassistant.components.neato.* homeassistant.components.neato.*
homeassistant.components.nest.* homeassistant.components.nest.*
@@ -387,7 +375,6 @@ homeassistant.components.no_ip.*
homeassistant.components.nordpool.* homeassistant.components.nordpool.*
homeassistant.components.notify.* homeassistant.components.notify.*
homeassistant.components.notion.* homeassistant.components.notion.*
homeassistant.components.nrgkick.*
homeassistant.components.ntfy.* homeassistant.components.ntfy.*
homeassistant.components.number.* homeassistant.components.number.*
homeassistant.components.nut.* homeassistant.components.nut.*
@@ -395,13 +382,11 @@ homeassistant.components.ohme.*
homeassistant.components.onboarding.* homeassistant.components.onboarding.*
homeassistant.components.oncue.* homeassistant.components.oncue.*
homeassistant.components.onedrive.* homeassistant.components.onedrive.*
homeassistant.components.onedrive_for_business.*
homeassistant.components.onewire.* homeassistant.components.onewire.*
homeassistant.components.onkyo.* homeassistant.components.onkyo.*
homeassistant.components.open_meteo.* homeassistant.components.open_meteo.*
homeassistant.components.open_router.* homeassistant.components.open_router.*
homeassistant.components.openai_conversation.* homeassistant.components.openai_conversation.*
homeassistant.components.openevse.*
homeassistant.components.openexchangerates.* homeassistant.components.openexchangerates.*
homeassistant.components.opensky.* homeassistant.components.opensky.*
homeassistant.components.openuv.* homeassistant.components.openuv.*
@@ -409,7 +394,6 @@ homeassistant.components.opnsense.*
homeassistant.components.opower.* homeassistant.components.opower.*
homeassistant.components.oralb.* homeassistant.components.oralb.*
homeassistant.components.otbr.* homeassistant.components.otbr.*
homeassistant.components.otp.*
homeassistant.components.overkiz.* homeassistant.components.overkiz.*
homeassistant.components.overseerr.* homeassistant.components.overseerr.*
homeassistant.components.p1_monitor.* homeassistant.components.p1_monitor.*
@@ -423,10 +407,8 @@ homeassistant.components.person.*
homeassistant.components.pi_hole.* homeassistant.components.pi_hole.*
homeassistant.components.ping.* homeassistant.components.ping.*
homeassistant.components.plugwise.* homeassistant.components.plugwise.*
homeassistant.components.pooldose.*
homeassistant.components.portainer.* homeassistant.components.portainer.*
homeassistant.components.powerfox.* homeassistant.components.powerfox.*
homeassistant.components.powerfox_local.*
homeassistant.components.powerwall.* homeassistant.components.powerwall.*
homeassistant.components.private_ble_device.* homeassistant.components.private_ble_device.*
homeassistant.components.prometheus.* homeassistant.components.prometheus.*
@@ -445,13 +427,10 @@ homeassistant.components.radarr.*
homeassistant.components.radio_browser.* homeassistant.components.radio_browser.*
homeassistant.components.rainforest_raven.* homeassistant.components.rainforest_raven.*
homeassistant.components.rainmachine.* homeassistant.components.rainmachine.*
homeassistant.components.random.*
homeassistant.components.raspberry_pi.* homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.* homeassistant.components.rdw.*
homeassistant.components.recollect_waste.* homeassistant.components.recollect_waste.*
homeassistant.components.recorder.* homeassistant.components.recorder.*
homeassistant.components.recovery_mode.*
homeassistant.components.redgtech.*
homeassistant.components.remember_the_milk.* homeassistant.components.remember_the_milk.*
homeassistant.components.remote.* homeassistant.components.remote.*
homeassistant.components.remote_calendar.* homeassistant.components.remote_calendar.*
@@ -475,14 +454,12 @@ homeassistant.components.russound_rio.*
homeassistant.components.ruuvi_gateway.* homeassistant.components.ruuvi_gateway.*
homeassistant.components.ruuvitag_ble.* homeassistant.components.ruuvitag_ble.*
homeassistant.components.samsungtv.* homeassistant.components.samsungtv.*
homeassistant.components.saunum.*
homeassistant.components.scene.* homeassistant.components.scene.*
homeassistant.components.schedule.* homeassistant.components.schedule.*
homeassistant.components.schlage.* homeassistant.components.schlage.*
homeassistant.components.scrape.* homeassistant.components.scrape.*
homeassistant.components.script.* homeassistant.components.script.*
homeassistant.components.search.* homeassistant.components.search.*
homeassistant.components.season.*
homeassistant.components.select.* homeassistant.components.select.*
homeassistant.components.sensibo.* homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.* homeassistant.components.sensirion_ble.*
@@ -509,7 +486,6 @@ homeassistant.components.smtp.*
homeassistant.components.snooz.* homeassistant.components.snooz.*
homeassistant.components.solarlog.* homeassistant.components.solarlog.*
homeassistant.components.sonarr.* homeassistant.components.sonarr.*
homeassistant.components.spaceapi.*
homeassistant.components.speedtestdotnet.* homeassistant.components.speedtestdotnet.*
homeassistant.components.spotify.* homeassistant.components.spotify.*
homeassistant.components.sql.* homeassistant.components.sql.*
@@ -534,7 +510,6 @@ homeassistant.components.synology_dsm.*
homeassistant.components.system_health.* homeassistant.components.system_health.*
homeassistant.components.system_log.* homeassistant.components.system_log.*
homeassistant.components.systemmonitor.* homeassistant.components.systemmonitor.*
homeassistant.components.systemnexa2.*
homeassistant.components.tag.* homeassistant.components.tag.*
homeassistant.components.tailscale.* homeassistant.components.tailscale.*
homeassistant.components.tailwind.* homeassistant.components.tailwind.*
@@ -545,7 +520,6 @@ homeassistant.components.tcp.*
homeassistant.components.technove.* homeassistant.components.technove.*
homeassistant.components.tedee.* homeassistant.components.tedee.*
homeassistant.components.telegram_bot.* homeassistant.components.telegram_bot.*
homeassistant.components.teslemetry.*
homeassistant.components.text.* homeassistant.components.text.*
homeassistant.components.thethingsnetwork.* homeassistant.components.thethingsnetwork.*
homeassistant.components.threshold.* homeassistant.components.threshold.*
@@ -578,14 +552,12 @@ homeassistant.components.update.*
homeassistant.components.uptime.* homeassistant.components.uptime.*
homeassistant.components.uptime_kuma.* homeassistant.components.uptime_kuma.*
homeassistant.components.uptimerobot.* homeassistant.components.uptimerobot.*
homeassistant.components.usage_prediction.*
homeassistant.components.usb.* homeassistant.components.usb.*
homeassistant.components.uvc.* homeassistant.components.uvc.*
homeassistant.components.vacuum.* homeassistant.components.vacuum.*
homeassistant.components.vallox.* homeassistant.components.vallox.*
homeassistant.components.valve.* homeassistant.components.valve.*
homeassistant.components.velbus.* homeassistant.components.velbus.*
homeassistant.components.velux.*
homeassistant.components.vivotek.* homeassistant.components.vivotek.*
homeassistant.components.vlc_telnet.* homeassistant.components.vlc_telnet.*
homeassistant.components.vodafone_station.* homeassistant.components.vodafone_station.*
@@ -595,10 +567,8 @@ homeassistant.components.wake_word.*
homeassistant.components.wallbox.* homeassistant.components.wallbox.*
homeassistant.components.waqi.* homeassistant.components.waqi.*
homeassistant.components.water_heater.* homeassistant.components.water_heater.*
homeassistant.components.watts.*
homeassistant.components.watttime.* homeassistant.components.watttime.*
homeassistant.components.weather.* homeassistant.components.weather.*
homeassistant.components.web_rtc.*
homeassistant.components.webhook.* homeassistant.components.webhook.*
homeassistant.components.webostv.* homeassistant.components.webostv.*
homeassistant.components.websocket_api.* homeassistant.components.websocket_api.*
@@ -615,7 +585,6 @@ homeassistant.components.yale_smart_alarm.*
homeassistant.components.yalexs_ble.* homeassistant.components.yalexs_ble.*
homeassistant.components.youtube.* homeassistant.components.youtube.*
homeassistant.components.zeroconf.* homeassistant.components.zeroconf.*
homeassistant.components.zinvolt.*
homeassistant.components.zodiac.* homeassistant.components.zodiac.*
homeassistant.components.zone.* homeassistant.components.zone.*
homeassistant.components.zwave_js.* homeassistant.components.zwave_js.*

View File

@@ -7,8 +7,8 @@
"python.testing.pytestEnabled": false, "python.testing.pytestEnabled": false,
// https://code.visualstudio.com/docs/python/linting#_general-settings // https://code.visualstudio.com/docs/python/linting#_general-settings
"pylint.importStrategy": "fromEnvironment", "pylint.importStrategy": "fromEnvironment",
// Pyright type checking is not compatible with mypy which Home Assistant uses for type checking // Pyright is too pedantic for Home Assistant
"python.analysis.typeCheckingMode": "off", "python.analysis.typeCheckingMode": "basic",
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff", "editor.defaultFormatter": "charliermarsh.ruff",
}, },

8
.vscode/tasks.json vendored
View File

@@ -45,7 +45,7 @@
{ {
"label": "Ruff", "label": "Ruff",
"type": "shell", "type": "shell",
"command": "prek run ruff-check --all-files", "command": "pre-commit run ruff-check --all-files",
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@@ -57,9 +57,9 @@
"problemMatcher": [] "problemMatcher": []
}, },
{ {
"label": "Prek", "label": "Pre-commit",
"type": "shell", "type": "shell",
"command": "prek run --show-diff-on-failure", "command": "pre-commit run --show-diff-on-failure",
"group": { "group": {
"kind": "test", "kind": "test",
"isDefault": true "isDefault": true
@@ -120,7 +120,7 @@
{ {
"label": "Generate Requirements", "label": "Generate Requirements",
"type": "shell", "type": "shell",
"command": "${command:python.interpreterPath} -m script.gen_requirements_all", "command": "./script/gen_requirements_all.py",
"group": { "group": {
"kind": "build", "kind": "build",
"isDefault": true "isDefault": true

328
AGENTS.md
View File

@@ -1,328 +0,0 @@
# GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application.
## Code Review Guidelines
**When reviewing code, do NOT comment on:**
- **Missing imports** - We use static analysis tooling to catch that
- **Code formatting** - We have ruff as a formatting tool that will catch those if needed (unless specifically instructed otherwise in these instructions)
**Git commit practices during review:**
- **Do NOT amend, squash, or rebase commits after review has started** - Reviewers need to see what changed since their last review
## Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use the newest features when possible:
- Pattern matching
- Type hints
- f-strings (preferred over `%` or `.format()`)
- Dataclasses
- Walrus operator
### Strict Typing (Platinum)
- **Comprehensive Type Hints**: Add type hints to all functions, methods, and variables
- **Custom Config Entry Types**: When using runtime_data:
```python
type MyIntegrationConfigEntry = ConfigEntry[MyClient]
```
- **Library Requirements**: Include `py.typed` file for PEP-561 compliance
## Code Quality Standards
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Lint/Type/Format Fixes**: Always prefer addressing the underlying issue (e.g., import the typed source, update shared stubs, align with Ruff expectations, or correct formatting at the source) before disabling a rule, adding `# type: ignore`, or skipping a formatter. Treat suppressions and `noqa` comments as a last resort once no compliant fix exists
- **Testing**: pytest with plain functions and fixtures
- **Language**: American English for all code, comments, and documentation (use sentence case, including titles)
### Writing Style Guidelines
- **Tone**: Friendly and informative
- **Perspective**: Use second-person ("you" and "your") for user-facing messages
- **Inclusivity**: Use objective, non-discriminatory language
- **Clarity**: Write for non-native English speakers
- **Formatting in Messages**:
- Use backticks for: file paths, filenames, variable names, field entries
- Use sentence case for titles and messages (capitalize only the first word and proper nouns)
- Avoid abbreviations when possible
### Documentation Standards
- **File Headers**: Short and concise
```python
"""Integration for Peblar EV chargers."""
```
- **Method/Function Docstrings**: Required for all
```python
async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
"""Set up Peblar from a config entry."""
```
- **Comment Style**:
- Use clear, descriptive comments
- Explain the "why" not just the "what"
- Keep code block lines under 80 characters when possible
- Use progressive disclosure (simple explanation first, complex details later)
## Async Programming
- All external I/O operations must be async
- **Best Practices**:
- Avoid sleeping in loops
- Avoid awaiting in loops - use `gather` instead
- No blocking calls
- Group executor jobs when possible - switching between event loop and executor is expensive
### Blocking Operations
- **Use Executor**: For blocking I/O operations
```python
result = await hass.async_add_executor_job(blocking_function, args)
```
- **Never Block Event Loop**: Avoid file operations, `time.sleep()`, blocking HTTP calls
- **Replace with Async**: Use `asyncio.sleep()` instead of `time.sleep()`
### Thread Safety
- **@callback Decorator**: For event loop safe functions
```python
@callback
def async_update_callback(self, event):
"""Safe to run in event loop."""
self.async_write_ha_state()
```
- **Sync APIs from Threads**: Use sync versions when calling from non-event loop threads
- **Registry Changes**: Must be done in event loop thread
### Error Handling
- **Exception Types**: Choose most specific exception available
- `ServiceValidationError`: User input errors (preferred over `ValueError`)
- `HomeAssistantError`: Device communication failures
- `ConfigEntryNotReady`: Temporary setup issues (device offline)
- `ConfigEntryAuthFailed`: Authentication problems
- `ConfigEntryError`: Permanent setup issues
- **Try/Catch Best Practices**:
- Only wrap code that can throw exceptions
- Keep try blocks minimal - process data after the try/catch
- **Avoid bare exceptions** except in specific cases:
- ❌ Generally not allowed: `except:` or `except Exception:`
- ✅ Allowed in config flows to ensure robustness
- ✅ Allowed in functions/methods that run in background tasks
- Bad pattern:
```python
try:
data = await device.get_data() # Can throw
# ❌ Don't process data inside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
except DeviceError:
_LOGGER.error("Failed to get data")
```
- Good pattern:
```python
try:
data = await device.get_data() # Can throw
except DeviceError:
_LOGGER.error("Failed to get data")
return
# ✅ Process data outside try block
processed = data.get("value", 0) * 100
self._attr_native_value = processed
```
- **Bare Exception Usage**:
```python
# ❌ Not allowed in regular code
try:
data = await device.get_data()
except Exception: # Too broad
_LOGGER.error("Failed")
# ✅ Allowed in config flow for robustness
async def async_step_user(self, user_input=None):
try:
await self._test_connection(user_input)
except Exception: # Allowed here
errors["base"] = "unknown"
# ✅ Allowed in background tasks
async def _background_refresh():
try:
await coordinator.async_refresh()
except Exception: # Allowed in task
_LOGGER.exception("Unexpected error in background task")
```
- **Setup Failure Patterns**:
```python
try:
await device.async_setup()
except (asyncio.TimeoutError, TimeoutException) as ex:
raise ConfigEntryNotReady(f"Timeout connecting to {device.host}") from ex
except AuthFailed as ex:
raise ConfigEntryAuthFailed(f"Credentials expired for {device.name}") from ex
```
### Logging
- **Format Guidelines**:
- No periods at end of messages
- No integration names/domains (added automatically)
- No sensitive data (keys, tokens, passwords)
- Use debug level for non-user-facing messages
- **Use Lazy Logging**:
```python
_LOGGER.debug("This is a log message with %s", variable)
```
### Unavailability Logging
- **Log Once**: When device/service becomes unavailable (info level)
- **Log Recovery**: When device/service comes back online
- **Implementation Pattern**:
```python
_unavailable_logged: bool = False
if not self._unavailable_logged:
_LOGGER.info("The sensor is unavailable: %s", ex)
self._unavailable_logged = True
# On recovery:
if self._unavailable_logged:
_LOGGER.info("The sensor is back online")
self._unavailable_logged = False
```
## Development Commands
### Environment
- **Local development (non-container)**: Activate the project venv before running commands: `source .venv/bin/activate`
- **Dev container**: No activation needed, the environment is pre-configured
### Code Quality & Linting
- **Run all linters on all files**: `prek run --all-files`
- **Run linters on staged files only**: `prek run`
- **PyLint on everything** (slow): `pylint homeassistant`
- **PyLint on specific folder**: `pylint homeassistant/components/my_integration`
- **MyPy type checking (whole project)**: `mypy homeassistant/`
- **MyPy on specific integration**: `mypy homeassistant/components/my_integration`
### Testing
- **Quick test of changed files**: `pytest --timeout=10 --picked`
- **Update test snapshots**: Add `--snapshot-update` to pytest command
- ⚠️ Omit test results after using `--snapshot-update`
- Always run tests again without the flag to verify snapshots
- **Full test suite** (AVOID - very slow): `pytest ./tests`
### Dependencies & Requirements
- **Update generated files after dependency changes**: `python -m script.gen_requirements_all`
- **Install all Python requirements**:
```bash
uv pip install -r requirements_all.txt -r requirements.txt -r requirements_test.txt
```
- **Install test requirements only**:
```bash
uv pip install -r requirements_test_all.txt -r requirements.txt
```
### Translations
- **Update translations after strings.json changes**:
```bash
python -m script.translations develop --all
```
### Project Validation
- **Run hassfest** (checks project structure and updates generated files):
```bash
python -m script.hassfest
```
## Common Anti-Patterns & Best Practices
### ❌ **Avoid These Patterns**
```python
# Blocking operations in event loop
data = requests.get(url) # ❌ Blocks event loop
time.sleep(5) # ❌ Blocks event loop
# Reusing BleakClient instances
self.client = BleakClient(address)
await self.client.connect()
# Later...
await self.client.connect() # ❌ Don't reuse
# Hardcoded strings in code
self._attr_name = "Temperature Sensor" # ❌ Not translatable
# Missing error handling
data = await self.api.get_data() # ❌ No exception handling
# Storing sensitive data in diagnostics
return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets
# Accessing hass.data directly in tests
coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data
# User-configurable polling intervals
# In config flow
vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed
# In coordinator
update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed
# User-configurable config entry names (non-helper integrations)
vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations
# Too much code in try block
try:
response = await client.get_data() # Can throw
# ❌ Data processing should be outside try block
temperature = response["temperature"] / 10
humidity = response["humidity"]
self._attr_native_value = temperature
except ClientError:
_LOGGER.error("Failed to fetch data")
# Bare exceptions in regular code
try:
value = await sensor.read_value()
except Exception: # ❌ Too broad - catch specific exceptions
_LOGGER.error("Failed to read sensor")
```
### ✅ **Use These Patterns Instead**
```python
# Async operations with executor
data = await hass.async_add_executor_job(requests.get, url)
await asyncio.sleep(5) # ✅ Non-blocking
# Fresh BleakClient instances
client = BleakClient(address) # ✅ New instance each time
await client.connect()
# Translatable entity names
_attr_translation_key = "temperature_sensor" # ✅ Translatable
# Proper error handling
try:
data = await self.api.get_data()
except ApiException as err:
raise UpdateFailed(f"API error: {err}") from err
# Redacted diagnostics data
return async_redact_data(data, {"api_key", "password"}) # ✅ Safe
# Test through proper integration setup and fixtures
@pytest.fixture
async def init_integration(hass, mock_config_entry, mock_api):
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup
# Integration-determined polling intervals (not user-configurable)
SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py
class MyCoordinator(DataUpdateCoordinator[MyData]):
def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None:
# ✅ Integration determines interval based on device capabilities, connection type, etc.
interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL
super().__init__(
hass,
logger=LOGGER,
name=DOMAIN,
update_interval=interval,
config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended
)
```

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -1 +1 @@
AGENTS.md .github/copilot-instructions.md

143
CODEOWNERS generated
View File

@@ -15,7 +15,7 @@
.yamllint @home-assistant/core .yamllint @home-assistant/core
pyproject.toml @home-assistant/core pyproject.toml @home-assistant/core
requirements_test.txt @home-assistant/core requirements_test.txt @home-assistant/core
/.devcontainer/ @home-assistant/core @edenhaus /.devcontainer/ @home-assistant/core
/.github/ @home-assistant/core /.github/ @home-assistant/core
/.vscode/ @home-assistant/core /.vscode/ @home-assistant/core
/homeassistant/*.py @home-assistant/core /homeassistant/*.py @home-assistant/core
@@ -73,8 +73,6 @@ build.json @home-assistant/supervisor
/tests/components/airobot/ @mettolen /tests/components/airobot/ @mettolen
/homeassistant/components/airos/ @CoMPaTech /homeassistant/components/airos/ @CoMPaTech
/tests/components/airos/ @CoMPaTech /tests/components/airos/ @CoMPaTech
/homeassistant/components/airpatrol/ @antondalgren
/tests/components/airpatrol/ @antondalgren
/homeassistant/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airq/ @Sibgatulin @dl2080
/tests/components/airq/ @Sibgatulin @dl2080 /tests/components/airq/ @Sibgatulin @dl2080
/homeassistant/components/airthings/ @danielhiversen @LaStrada /homeassistant/components/airthings/ @danielhiversen @LaStrada
@@ -220,8 +218,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/bizkaibus/ @UgaitzEtxebarria /homeassistant/components/bizkaibus/ @UgaitzEtxebarria
/homeassistant/components/blebox/ @bbx-a @swistakm /homeassistant/components/blebox/ @bbx-a @swistakm
/tests/components/blebox/ @bbx-a @swistakm /tests/components/blebox/ @bbx-a @swistakm
/homeassistant/components/blink/ @fronzbot /homeassistant/components/blink/ @fronzbot @mkmer
/tests/components/blink/ @fronzbot /tests/components/blink/ @fronzbot @mkmer
/homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /homeassistant/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23 /tests/components/blue_current/ @gleeuwen @NickKoepr @jtodorova23
/homeassistant/components/bluemaestro/ @bdraco /homeassistant/components/bluemaestro/ @bdraco
@@ -242,8 +240,6 @@ build.json @home-assistant/supervisor
/tests/components/bosch_alarm/ @mag1024 @sanjay900 /tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm /homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm /tests/components/bosch_shc/ @tschamm
/homeassistant/components/brands/ @home-assistant/core
/tests/components/brands/ @home-assistant/core
/homeassistant/components/braviatv/ @bieniu @Drafteed /homeassistant/components/braviatv/ @bieniu @Drafteed
/tests/components/braviatv/ @bieniu @Drafteed /tests/components/braviatv/ @bieniu @Drafteed
/homeassistant/components/bring/ @miaucl @tr4nt0r /homeassistant/components/bring/ @miaucl @tr4nt0r
@@ -290,8 +286,6 @@ build.json @home-assistant/supervisor
/tests/components/cloud/ @home-assistant/cloud /tests/components/cloud/ @home-assistant/cloud
/homeassistant/components/cloudflare/ @ludeeus @ctalkington /homeassistant/components/cloudflare/ @ludeeus @ctalkington
/tests/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington
/homeassistant/components/cloudflare_r2/ @corrreia
/tests/components/cloudflare_r2/ @corrreia
/homeassistant/components/co2signal/ @jpbede @VIKTORVAV99 /homeassistant/components/co2signal/ @jpbede @VIKTORVAV99
/tests/components/co2signal/ @jpbede @VIKTORVAV99 /tests/components/co2signal/ @jpbede @VIKTORVAV99
/homeassistant/components/coinbase/ @tombrien /homeassistant/components/coinbase/ @tombrien
@@ -312,8 +306,8 @@ build.json @home-assistant/supervisor
/tests/components/config/ @home-assistant/core /tests/components/config/ @home-assistant/core
/homeassistant/components/configurator/ @home-assistant/core /homeassistant/components/configurator/ @home-assistant/core
/tests/components/configurator/ @home-assistant/core /tests/components/configurator/ @home-assistant/core
/homeassistant/components/control4/ @lawtancool @davidrecordon /homeassistant/components/control4/ @lawtancool
/tests/components/control4/ @lawtancool @davidrecordon /tests/components/control4/ @lawtancool
/homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/conversation/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/cookidoo/ @miaucl /homeassistant/components/cookidoo/ @miaucl
@@ -401,10 +395,12 @@ build.json @home-assistant/supervisor
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna /tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
/homeassistant/components/duckdns/ @tr4nt0r /homeassistant/components/duckdns/ @tr4nt0r
/tests/components/duckdns/ @tr4nt0r /tests/components/duckdns/ @tr4nt0r
/homeassistant/components/duke_energy/ @hunterjm
/tests/components/duke_energy/ @hunterjm
/homeassistant/components/duotecno/ @cereal2nd /homeassistant/components/duotecno/ @cereal2nd
/tests/components/duotecno/ @cereal2nd /tests/components/duotecno/ @cereal2nd
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 /tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @andarotajo
/homeassistant/components/dynalite/ @ziv1234 /homeassistant/components/dynalite/ @ziv1234
/tests/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234
/homeassistant/components/eafm/ @Jc2k /homeassistant/components/eafm/ @Jc2k
@@ -422,8 +418,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/efergy/ @tkdrob /homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/egardia/ @jeroenterheerdt
/homeassistant/components/egauge/ @neggert
/tests/components/egauge/ @neggert
/homeassistant/components/eheimdigital/ @autinerd /homeassistant/components/eheimdigital/ @autinerd
/tests/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd
/homeassistant/components/ekeybionyx/ @richardpolzer /homeassistant/components/ekeybionyx/ @richardpolzer
@@ -466,7 +460,7 @@ build.json @home-assistant/supervisor
/tests/components/enigma2/ @autinerd /tests/components/enigma2/ @autinerd
/homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /homeassistant/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac /tests/components/enphase_envoy/ @bdraco @cgarwood @catsmanac
/homeassistant/components/entur_public_transport/ @hfurubotten @SanderBlom /homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie /homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie /tests/components/environment_canada/ @gwww @michaeldavie
/homeassistant/components/ephember/ @ttroy50 @roberty99 /homeassistant/components/ephember/ @ttroy50 @roberty99
@@ -518,8 +512,6 @@ build.json @home-assistant/supervisor
/tests/components/fireservicerota/ @cyberjunky /tests/components/fireservicerota/ @cyberjunky
/homeassistant/components/firmata/ @DaAwesomeP /homeassistant/components/firmata/ @DaAwesomeP
/tests/components/firmata/ @DaAwesomeP /tests/components/firmata/ @DaAwesomeP
/homeassistant/components/fish_audio/ @noambav
/tests/components/fish_audio/ @noambav
/homeassistant/components/fitbit/ @allenporter /homeassistant/components/fitbit/ @allenporter
/tests/components/fitbit/ @allenporter /tests/components/fitbit/ @allenporter
/homeassistant/components/fivem/ @Sander0542 /homeassistant/components/fivem/ @Sander0542
@@ -534,8 +526,6 @@ build.json @home-assistant/supervisor
/tests/components/flo/ @dmulcahey /tests/components/flo/ @dmulcahey
/homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor
/tests/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor
/homeassistant/components/fluss/ @fluss
/tests/components/fluss/ @fluss
/homeassistant/components/flux_led/ @icemanch /homeassistant/components/flux_led/ @icemanch
/tests/components/flux_led/ @icemanch /tests/components/flux_led/ @icemanch
/homeassistant/components/forecast_solar/ @klaasnicolaas @frenck /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck
@@ -555,6 +545,8 @@ build.json @home-assistant/supervisor
/tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185 /tests/components/fritz/ @AaronDavidSchneider @chemelli74 @mib1185
/homeassistant/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox/ @mib1185 @flabbamann
/tests/components/fritzbox/ @mib1185 @flabbamann /tests/components/fritzbox/ @mib1185 @flabbamann
/homeassistant/components/fritzbox_callmonitor/ @cdce8p
/tests/components/fritzbox_callmonitor/ @cdce8p
/homeassistant/components/fronius/ @farmio /homeassistant/components/fronius/ @farmio
/tests/components/fronius/ @farmio /tests/components/fronius/ @farmio
/homeassistant/components/frontend/ @home-assistant/frontend /homeassistant/components/frontend/ @home-assistant/frontend
@@ -579,8 +571,6 @@ build.json @home-assistant/supervisor
/tests/components/generic_hygrostat/ @Shulyaka /tests/components/generic_hygrostat/ @Shulyaka
/homeassistant/components/geniushub/ @manzanotti /homeassistant/components/geniushub/ @manzanotti
/tests/components/geniushub/ @manzanotti /tests/components/geniushub/ @manzanotti
/homeassistant/components/gentex_homelink/ @niaexa @ryanjones-gentex
/tests/components/gentex_homelink/ @niaexa @ryanjones-gentex
/homeassistant/components/geo_json_events/ @exxamalte /homeassistant/components/geo_json_events/ @exxamalte
/tests/components/geo_json_events/ @exxamalte /tests/components/geo_json_events/ @exxamalte
/homeassistant/components/geo_location/ @home-assistant/core /homeassistant/components/geo_location/ @home-assistant/core
@@ -593,8 +583,6 @@ build.json @home-assistant/supervisor
/tests/components/geonetnz_quakes/ @exxamalte /tests/components/geonetnz_quakes/ @exxamalte
/homeassistant/components/geonetnz_volcano/ @exxamalte /homeassistant/components/geonetnz_volcano/ @exxamalte
/tests/components/geonetnz_volcano/ @exxamalte /tests/components/geonetnz_volcano/ @exxamalte
/homeassistant/components/ghost/ @johnonolan
/tests/components/ghost/ @johnonolan
/homeassistant/components/gios/ @bieniu /homeassistant/components/gios/ @bieniu
/tests/components/gios/ @bieniu /tests/components/gios/ @bieniu
/homeassistant/components/github/ @timmo001 @ludeeus /homeassistant/components/github/ @timmo001 @ludeeus
@@ -643,8 +631,6 @@ build.json @home-assistant/supervisor
/tests/components/gpsd/ @fabaff @jrieger /tests/components/gpsd/ @fabaff @jrieger
/homeassistant/components/gree/ @cmroche /homeassistant/components/gree/ @cmroche
/tests/components/gree/ @cmroche /tests/components/gree/ @cmroche
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greeneye_monitor/ @jkeljo /homeassistant/components/greeneye_monitor/ @jkeljo
/tests/components/greeneye_monitor/ @jkeljo /tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core /homeassistant/components/group/ @home-assistant/core
@@ -665,19 +651,14 @@ build.json @home-assistant/supervisor
/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan
/homeassistant/components/hassio/ @home-assistant/supervisor /homeassistant/components/hassio/ @home-assistant/supervisor
/tests/components/hassio/ @home-assistant/supervisor /tests/components/hassio/ @home-assistant/supervisor
/homeassistant/components/hdfury/ @glenndehaan
/tests/components/hdfury/ @glenndehaan
/homeassistant/components/hdmi_cec/ @inytar /homeassistant/components/hdmi_cec/ @inytar
/tests/components/hdmi_cec/ @inytar /tests/components/hdmi_cec/ @inytar
/homeassistant/components/heatmiser/ @andylockran /homeassistant/components/heatmiser/ @andylockran
/homeassistant/components/hegel/ @boazca
/tests/components/hegel/ @boazca
/homeassistant/components/heos/ @andrewsayre /homeassistant/components/heos/ @andrewsayre
/tests/components/heos/ @andrewsayre /tests/components/heos/ @andrewsayre
/homeassistant/components/here_travel_time/ @eifinger /homeassistant/components/here_travel_time/ @eifinger
/tests/components/here_travel_time/ @eifinger /tests/components/here_travel_time/ @eifinger
/homeassistant/components/hikvision/ @mezz64 @ptarjan /homeassistant/components/hikvision/ @mezz64
/tests/components/hikvision/ @mezz64 @ptarjan
/homeassistant/components/hikvisioncam/ @fbradyirl /homeassistant/components/hikvisioncam/ @fbradyirl
/homeassistant/components/hisense_aehw4a1/ @bannhead /homeassistant/components/hisense_aehw4a1/ @bannhead
/tests/components/hisense_aehw4a1/ @bannhead /tests/components/hisense_aehw4a1/ @bannhead
@@ -715,10 +696,8 @@ build.json @home-assistant/supervisor
/tests/components/homekit_controller/ @Jc2k @bdraco /tests/components/homekit_controller/ @Jc2k @bdraco
/homeassistant/components/homematic/ @pvizeli /homeassistant/components/homematic/ @pvizeli
/tests/components/homematic/ @pvizeli /tests/components/homematic/ @pvizeli
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas /homeassistant/components/homematicip_cloud/ @hahn-th
/tests/components/homematicip_cloud/ @hahn-th @lackas /tests/components/homematicip_cloud/ @hahn-th
/homeassistant/components/homevolt/ @danielhiversen @liudger
/tests/components/homevolt/ @danielhiversen @liudger
/homeassistant/components/homewizard/ @DCSBL /homeassistant/components/homewizard/ @DCSBL
/tests/components/homewizard/ @DCSBL /tests/components/homewizard/ @DCSBL
/homeassistant/components/honeywell/ @rdfurman @mkmer /homeassistant/components/honeywell/ @rdfurman @mkmer
@@ -751,8 +730,6 @@ build.json @home-assistant/supervisor
/tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan /tests/components/hydrawise/ @dknowles2 @thomaskistler @ptcryan
/homeassistant/components/hyperion/ @dermotduffy /homeassistant/components/hyperion/ @dermotduffy
/tests/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy
/homeassistant/components/hypontech/ @jcisio
/tests/components/hypontech/ @jcisio
/homeassistant/components/ialarm/ @RyuzakiKK /homeassistant/components/ialarm/ @RyuzakiKK
/tests/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK
/homeassistant/components/iammeter/ @lewei50 /homeassistant/components/iammeter/ @lewei50
@@ -762,8 +739,6 @@ build.json @home-assistant/supervisor
/tests/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi
/homeassistant/components/idasen_desk/ @abmantis /homeassistant/components/idasen_desk/ @abmantis
/tests/components/idasen_desk/ @abmantis /tests/components/idasen_desk/ @abmantis
/homeassistant/components/idrive_e2/ @patrickvorgers
/tests/components/idrive_e2/ @patrickvorgers
/homeassistant/components/igloohome/ @keithle888 /homeassistant/components/igloohome/ @keithle888
/tests/components/igloohome/ @keithle888 /tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte /homeassistant/components/ign_sismologia/ @exxamalte
@@ -786,14 +761,10 @@ build.json @home-assistant/supervisor
/tests/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery
/homeassistant/components/incomfort/ @jbouwh /homeassistant/components/incomfort/ @jbouwh
/tests/components/incomfort/ @jbouwh /tests/components/incomfort/ @jbouwh
/homeassistant/components/indevolt/ @xirtnl
/tests/components/indevolt/ @xirtnl
/homeassistant/components/inels/ @epdevlab /homeassistant/components/inels/ @epdevlab
/tests/components/inels/ @epdevlab /tests/components/inels/ @epdevlab
/homeassistant/components/influxdb/ @mdegat01 @Robbie1221 /homeassistant/components/influxdb/ @mdegat01
/tests/components/influxdb/ @mdegat01 @Robbie1221 /tests/components/influxdb/ @mdegat01
/homeassistant/components/infrared/ @home-assistant/core
/tests/components/infrared/ @home-assistant/core
/homeassistant/components/inkbird/ @bdraco /homeassistant/components/inkbird/ @bdraco
/tests/components/inkbird/ @bdraco /tests/components/inkbird/ @bdraco
/homeassistant/components/input_boolean/ @home-assistant/core /homeassistant/components/input_boolean/ @home-assistant/core
@@ -812,14 +783,10 @@ build.json @home-assistant/supervisor
/tests/components/insteon/ @teharris1 /tests/components/insteon/ @teharris1
/homeassistant/components/integration/ @dgomes /homeassistant/components/integration/ @dgomes
/tests/components/integration/ @dgomes /tests/components/integration/ @dgomes
/homeassistant/components/intelliclima/ @dvdinth
/tests/components/intelliclima/ @dvdinth
/homeassistant/components/intellifire/ @jeeftor /homeassistant/components/intellifire/ @jeeftor
/tests/components/intellifire/ @jeeftor /tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz /tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo /homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs /homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs /tests/components/iometer/ @jukrebs
@@ -935,8 +902,6 @@ build.json @home-assistant/supervisor
/tests/components/libre_hardware_monitor/ @Sab44 /tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lidarr/ @tkdrob /homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob /tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
/tests/components/liebherr/ @mettolen
/homeassistant/components/lifx/ @Djelibeybi /homeassistant/components/lifx/ @Djelibeybi
/tests/components/lifx/ @Djelibeybi /tests/components/lifx/ @Djelibeybi
/homeassistant/components/light/ @home-assistant/core /homeassistant/components/light/ @home-assistant/core
@@ -1037,8 +1002,8 @@ build.json @home-assistant/supervisor
/tests/components/mill/ @danielhiversen /tests/components/mill/ @danielhiversen
/homeassistant/components/min_max/ @gjohansson-ST /homeassistant/components/min_max/ @gjohansson-ST
/tests/components/min_max/ @gjohansson-ST /tests/components/min_max/ @gjohansson-ST
/homeassistant/components/minecraft_server/ @elmurato @zachdeibert /homeassistant/components/minecraft_server/ @elmurato
/tests/components/minecraft_server/ @elmurato @zachdeibert /tests/components/minecraft_server/ @elmurato
/homeassistant/components/minio/ @tkislan /homeassistant/components/minio/ @tkislan
/tests/components/minio/ @tkislan /tests/components/minio/ @tkislan
/homeassistant/components/moat/ @bdraco /homeassistant/components/moat/ @bdraco
@@ -1072,8 +1037,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind /homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mta/ @OnFreund
/tests/components/mta/ @OnFreund
/homeassistant/components/mullvad/ @meichthys /homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys /tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz /homeassistant/components/music_assistant/ @music-assistant @arturpragacz
@@ -1082,8 +1045,6 @@ build.json @home-assistant/supervisor
/tests/components/mutesync/ @currentoor /tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core /homeassistant/components/my/ @home-assistant/core
/tests/components/my/ @home-assistant/core /tests/components/my/ @home-assistant/core
/homeassistant/components/myneomitis/ @l-pr
/tests/components/myneomitis/ @l-pr
/homeassistant/components/mysensors/ @MartinHjelmare @functionpointer /homeassistant/components/mysensors/ @MartinHjelmare @functionpointer
/tests/components/mysensors/ @MartinHjelmare @functionpointer /tests/components/mysensors/ @MartinHjelmare @functionpointer
/homeassistant/components/mystrom/ @fabaff /homeassistant/components/mystrom/ @fabaff
@@ -1092,16 +1053,14 @@ build.json @home-assistant/supervisor
/tests/components/myuplink/ @pajzo @astrandb /tests/components/myuplink/ @pajzo @astrandb
/homeassistant/components/nam/ @bieniu /homeassistant/components/nam/ @bieniu
/tests/components/nam/ @bieniu /tests/components/nam/ @bieniu
/homeassistant/components/namecheapdns/ @tr4nt0r /homeassistant/components/nanoleaf/ @milanmeu @joostlek
/tests/components/namecheapdns/ @tr4nt0r /tests/components/nanoleaf/ @milanmeu @joostlek
/homeassistant/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/tests/components/nanoleaf/ @milanmeu @joostlek @loebi-ch @JaspervRijbroek @jonathanrobichaud4
/homeassistant/components/nasweb/ @nasWebio /homeassistant/components/nasweb/ @nasWebio
/tests/components/nasweb/ @nasWebio /tests/components/nasweb/ @nasWebio
/homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /homeassistant/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul /tests/components/nederlandse_spoorwegen/ @YarmoM @heindrichpaul
/homeassistant/components/ness_alarm/ @nickw444 @poshy163 /homeassistant/components/ness_alarm/ @nickw444
/tests/components/ness_alarm/ @nickw444 @poshy163 /tests/components/ness_alarm/ @nickw444
/homeassistant/components/nest/ @allenporter /homeassistant/components/nest/ @allenporter
/tests/components/nest/ @allenporter /tests/components/nest/ @allenporter
/homeassistant/components/netatmo/ @cgtobi /homeassistant/components/netatmo/ @cgtobi
@@ -1148,8 +1107,6 @@ build.json @home-assistant/supervisor
/tests/components/notify_events/ @matrozov @papajojo /tests/components/notify_events/ @matrozov @papajojo
/homeassistant/components/notion/ @bachya /homeassistant/components/notion/ @bachya
/tests/components/notion/ @bachya /tests/components/notion/ @bachya
/homeassistant/components/nrgkick/ @andijakl
/tests/components/nrgkick/ @andijakl
/homeassistant/components/nsw_fuel_station/ @nickw444 /homeassistant/components/nsw_fuel_station/ @nickw444
/tests/components/nsw_fuel_station/ @nickw444 /tests/components/nsw_fuel_station/ @nickw444
/homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte /homeassistant/components/nsw_rural_fire_service_feed/ @exxamalte
@@ -1188,8 +1145,6 @@ build.json @home-assistant/supervisor
/tests/components/ondilo_ico/ @JeromeHXP /tests/components/ondilo_ico/ @JeromeHXP
/homeassistant/components/onedrive/ @zweckj /homeassistant/components/onedrive/ @zweckj
/tests/components/onedrive/ @zweckj /tests/components/onedrive/ @zweckj
/homeassistant/components/onedrive_for_business/ @zweckj
/tests/components/onedrive_for_business/ @zweckj
/homeassistant/components/onewire/ @garbled1 @epenet /homeassistant/components/onewire/ @garbled1 @epenet
/tests/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet
/homeassistant/components/onkyo/ @arturpragacz @eclair4151 /homeassistant/components/onkyo/ @arturpragacz @eclair4151
@@ -1202,8 +1157,6 @@ build.json @home-assistant/supervisor
/tests/components/open_router/ @joostlek /tests/components/open_router/ @joostlek
/homeassistant/components/openerz/ @misialq /homeassistant/components/openerz/ @misialq
/tests/components/openerz/ @misialq /tests/components/openerz/ @misialq
/homeassistant/components/openevse/ @c00w @firstof9
/tests/components/openevse/ @c00w @firstof9
/homeassistant/components/openexchangerates/ @MartinHjelmare /homeassistant/components/openexchangerates/ @MartinHjelmare
/tests/components/openexchangerates/ @MartinHjelmare /tests/components/openexchangerates/ @MartinHjelmare
/homeassistant/components/opengarage/ @danielhiversen /homeassistant/components/opengarage/ @danielhiversen
@@ -1235,8 +1188,8 @@ build.json @home-assistant/supervisor
/tests/components/ourgroceries/ @OnFreund /tests/components/ourgroceries/ @OnFreund
/homeassistant/components/overkiz/ @imicknl /homeassistant/components/overkiz/ @imicknl
/tests/components/overkiz/ @imicknl /tests/components/overkiz/ @imicknl
/homeassistant/components/overseerr/ @joostlek @AmGarera /homeassistant/components/overseerr/ @joostlek
/tests/components/overseerr/ @joostlek @AmGarera /tests/components/overseerr/ @joostlek
/homeassistant/components/ovo_energy/ @timmo001 /homeassistant/components/ovo_energy/ @timmo001
/tests/components/ovo_energy/ @timmo001 /tests/components/ovo_energy/ @timmo001
/homeassistant/components/p1_monitor/ @klaasnicolaas /homeassistant/components/p1_monitor/ @klaasnicolaas
@@ -1285,12 +1238,8 @@ build.json @home-assistant/supervisor
/tests/components/portainer/ @erwindouna /tests/components/portainer/ @erwindouna
/homeassistant/components/powerfox/ @klaasnicolaas /homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
/tests/components/powerfox_local/ @klaasnicolaas
/homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson
/tests/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson
/homeassistant/components/prana/ @prana-dev-official
/tests/components/prana/ @prana-dev-official
/homeassistant/components/private_ble_device/ @Jc2k /homeassistant/components/private_ble_device/ @Jc2k
/tests/components/private_ble_device/ @Jc2k /tests/components/private_ble_device/ @Jc2k
/homeassistant/components/probe_plus/ @pantherale0 /homeassistant/components/probe_plus/ @pantherale0
@@ -1305,8 +1254,7 @@ build.json @home-assistant/supervisor
/tests/components/prosegur/ @dgomes /tests/components/prosegur/ @dgomes
/homeassistant/components/proximity/ @mib1185 /homeassistant/components/proximity/ @mib1185
/tests/components/proximity/ @mib1185 /tests/components/proximity/ @mib1185
/homeassistant/components/proxmoxve/ @jhollowe @Corbeno @erwindouna /homeassistant/components/proxmoxve/ @jhollowe @Corbeno
/tests/components/proxmoxve/ @jhollowe @Corbeno @erwindouna
/homeassistant/components/ps4/ @ktnrg45 /homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45
/homeassistant/components/pterodactyl/ @elmurato /homeassistant/components/pterodactyl/ @elmurato
@@ -1377,8 +1325,6 @@ build.json @home-assistant/supervisor
/tests/components/recorder/ @home-assistant/core /tests/components/recorder/ @home-assistant/core
/homeassistant/components/recovery_mode/ @home-assistant/core /homeassistant/components/recovery_mode/ @home-assistant/core
/tests/components/recovery_mode/ @home-assistant/core /tests/components/recovery_mode/ @home-assistant/core
/homeassistant/components/redgtech/ @jonhsady @luan-nvg
/tests/components/redgtech/ @jonhsady @luan-nvg
/homeassistant/components/refoss/ @ashionky /homeassistant/components/refoss/ @ashionky
/tests/components/refoss/ @ashionky /tests/components/refoss/ @ashionky
/homeassistant/components/rehlko/ @bdraco @peterager /homeassistant/components/rehlko/ @bdraco @peterager
@@ -1410,8 +1356,8 @@ build.json @home-assistant/supervisor
/tests/components/ring/ @sdb9696 /tests/components/ring/ @sdb9696
/homeassistant/components/risco/ @OnFreund /homeassistant/components/risco/ @OnFreund
/tests/components/risco/ @OnFreund /tests/components/risco/ @OnFreund
/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck
/tests/components/rituals_perfume_genie/ @milanmeu @frenck @quebulm /tests/components/rituals_perfume_genie/ @milanmeu @frenck
/homeassistant/components/rmvtransport/ @cgtobi /homeassistant/components/rmvtransport/ @cgtobi
/tests/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi
/homeassistant/components/roborock/ @Lash-L @allenporter /homeassistant/components/roborock/ @Lash-L @allenporter
@@ -1585,7 +1531,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /homeassistant/components/speedtestdotnet/ @rohankapoorcom @engrbm87
/tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87 /tests/components/speedtestdotnet/ @rohankapoorcom @engrbm87
/homeassistant/components/splunk/ @Bre77 /homeassistant/components/splunk/ @Bre77
/tests/components/splunk/ @Bre77
/homeassistant/components/spotify/ @frenck @joostlek /homeassistant/components/spotify/ @frenck @joostlek
/tests/components/spotify/ @frenck @joostlek /tests/components/spotify/ @frenck @joostlek
/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira
@@ -1650,8 +1595,6 @@ build.json @home-assistant/supervisor
/tests/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001
/homeassistant/components/systemmonitor/ @gjohansson-ST /homeassistant/components/systemmonitor/ @gjohansson-ST
/tests/components/systemmonitor/ @gjohansson-ST /tests/components/systemmonitor/ @gjohansson-ST
/homeassistant/components/systemnexa2/ @konsulten @slangstrom
/tests/components/systemnexa2/ @konsulten @slangstrom
/homeassistant/components/tado/ @erwindouna /homeassistant/components/tado/ @erwindouna
/tests/components/tado/ @erwindouna /tests/components/tado/ @erwindouna
/homeassistant/components/tag/ @home-assistant/core /homeassistant/components/tag/ @home-assistant/core
@@ -1677,8 +1620,6 @@ build.json @home-assistant/supervisor
/tests/components/telegram_bot/ @hanwg /tests/components/telegram_bot/ @hanwg
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/template/ @Petro31 @home-assistant/core /homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core /tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
@@ -1745,10 +1686,8 @@ build.json @home-assistant/supervisor
/tests/components/trafikverket_train/ @gjohansson-ST /tests/components/trafikverket_train/ @gjohansson-ST
/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST /homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST
/tests/components/trafikverket_weatherstation/ @gjohansson-ST /tests/components/trafikverket_weatherstation/ @gjohansson-ST
/homeassistant/components/trane/ @bdraco /homeassistant/components/transmission/ @engrbm87 @JPHutchins
/tests/components/trane/ @bdraco /tests/components/transmission/ @engrbm87 @JPHutchins
/homeassistant/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/tests/components/transmission/ @engrbm87 @JPHutchins @andrew-codechimp
/homeassistant/components/trend/ @jpbede /homeassistant/components/trend/ @jpbede
/tests/components/trend/ @jpbede /tests/components/trend/ @jpbede
/homeassistant/components/triggercmd/ @rvmey /homeassistant/components/triggercmd/ @rvmey
@@ -1763,8 +1702,6 @@ build.json @home-assistant/supervisor
/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen
/homeassistant/components/twitch/ @joostlek /homeassistant/components/twitch/ @joostlek
/tests/components/twitch/ @joostlek /tests/components/twitch/ @joostlek
/homeassistant/components/uhoo/ @getuhoo @joshsmonta
/tests/components/uhoo/ @getuhoo @joshsmonta
/homeassistant/components/ukraine_alarm/ @PaulAnnekov /homeassistant/components/ukraine_alarm/ @PaulAnnekov
/tests/components/ukraine_alarm/ @PaulAnnekov /tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610 /homeassistant/components/unifi/ @Kane610
@@ -1826,7 +1763,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/vilfo/ @ManneW /homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW /tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel /homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325 /homeassistant/components/vizio/ @raman325
/tests/components/vizio/ @raman325 /tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare /homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare
@@ -1851,13 +1787,9 @@ build.json @home-assistant/supervisor
/tests/components/waqi/ @joostlek /tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core /homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core
/homeassistant/components/waterfurnace/ @sdague @masterkoppa
/tests/components/waterfurnace/ @sdague @masterkoppa
/homeassistant/components/watergate/ @adam-the-hero /homeassistant/components/watergate/ @adam-the-hero
/tests/components/watergate/ @adam-the-hero /tests/components/watergate/ @adam-the-hero
/homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watson_tts/ @rutkai
/homeassistant/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/tests/components/watts/ @theobld-ww @devender-verma-ww @ssi-spyro
/homeassistant/components/watttime/ @bachya /homeassistant/components/watttime/ @bachya
/tests/components/watttime/ @bachya /tests/components/watttime/ @bachya
/homeassistant/components/waze_travel_time/ @eifinger /homeassistant/components/waze_travel_time/ @eifinger
@@ -1870,8 +1802,6 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor /tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner /homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner
/homeassistant/components/web_rtc/ @home-assistant/core
/tests/components/web_rtc/ @home-assistant/core
/homeassistant/components/webdav/ @jpbede /homeassistant/components/webdav/ @jpbede
/tests/components/webdav/ @jpbede /tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core
@@ -1882,8 +1812,8 @@ build.json @home-assistant/supervisor
/tests/components/webostv/ @thecode /tests/components/webostv/ @thecode
/homeassistant/components/websocket_api/ @home-assistant/core /homeassistant/components/websocket_api/ @home-assistant/core
/tests/components/websocket_api/ @home-assistant/core /tests/components/websocket_api/ @home-assistant/core
/homeassistant/components/weheat/ @barryvdh /homeassistant/components/weheat/ @jesperraemaekers
/tests/components/weheat/ @barryvdh /tests/components/weheat/ @jesperraemaekers
/homeassistant/components/wemo/ @esev /homeassistant/components/wemo/ @esev
/tests/components/wemo/ @esev /tests/components/wemo/ @esev
/homeassistant/components/whirlpool/ @abmantis @mkmer /homeassistant/components/whirlpool/ @abmantis @mkmer
@@ -1899,8 +1829,8 @@ build.json @home-assistant/supervisor
/tests/components/withings/ @joostlek /tests/components/withings/ @joostlek
/homeassistant/components/wiz/ @sbidy @arturpragacz /homeassistant/components/wiz/ @sbidy @arturpragacz
/tests/components/wiz/ @sbidy @arturpragacz /tests/components/wiz/ @sbidy @arturpragacz
/homeassistant/components/wled/ @frenck @mik-laj /homeassistant/components/wled/ @frenck
/tests/components/wled/ @frenck @mik-laj /tests/components/wled/ @frenck
/homeassistant/components/wmspro/ @mback2k /homeassistant/components/wmspro/ @mback2k
/tests/components/wmspro/ @mback2k /tests/components/wmspro/ @mback2k
/homeassistant/components/wolflink/ @adamkrol93 @mtielen /homeassistant/components/wolflink/ @adamkrol93 @mtielen
@@ -1911,8 +1841,6 @@ build.json @home-assistant/supervisor
/tests/components/worldclock/ @fabaff /tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger /homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger /tests/components/ws66i/ @ssaenger
/homeassistant/components/wsdot/ @ucodery
/tests/components/wsdot/ @ucodery
/homeassistant/components/wyoming/ @synesthesiam /homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam /tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r /homeassistant/components/xbox/ @hunterjm @tr4nt0r
@@ -1961,14 +1889,11 @@ build.json @home-assistant/supervisor
/tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES /tests/components/zha/ @dmulcahey @adminiuga @puddly @TheJulianJES
/homeassistant/components/zimi/ @markhannon /homeassistant/components/zimi/ @markhannon
/tests/components/zimi/ @markhannon /tests/components/zimi/ @markhannon
/homeassistant/components/zinvolt/ @joostlek
/tests/components/zinvolt/ @joostlek
/homeassistant/components/zodiac/ @JulienTant /homeassistant/components/zodiac/ @JulienTant
/tests/components/zodiac/ @JulienTant /tests/components/zodiac/ @JulienTant
/homeassistant/components/zone/ @home-assistant/core /homeassistant/components/zone/ @home-assistant/core
/tests/components/zone/ @home-assistant/core /tests/components/zone/ @home-assistant/core
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi /homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
/tests/components/zoneminder/ @rohankapoorcom @nabbi
/homeassistant/components/zwave_js/ @home-assistant/z-wave /homeassistant/components/zwave_js/ @home-assistant/z-wave
/tests/components/zwave_js/ @home-assistant/z-wave /tests/components/zwave_js/ @home-assistant/z-wave
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS /homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS

4
Dockerfile generated
View File

@@ -24,13 +24,13 @@ ENV \
COPY rootfs / COPY rootfs /
# Add go2rtc binary # Add go2rtc binary
COPY --from=ghcr.io/alexxit/go2rtc@sha256:675c318b23c06fd862a61d262240c9a63436b4050d177ffc68a32710d9e05bae /usr/local/bin/go2rtc /bin/go2rtc COPY --from=ghcr.io/alexxit/go2rtc@sha256:baef0aa19d759fcfd31607b34ce8eaf039d496282bba57731e6ae326896d7640 /usr/local/bin/go2rtc /bin/go2rtc
RUN \ RUN \
# Verify go2rtc can be executed # Verify go2rtc can be executed
go2rtc --version \ go2rtc --version \
# Install uv # Install uv
&& pip3 install uv==0.10.6 && pip3 install uv==0.9.6
WORKDIR /usr/src WORKDIR /usr/src

View File

@@ -52,9 +52,6 @@ RUN --mount=type=bind,source=requirements.txt,target=requirements.txt \
--mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \ --mount=type=bind,source=requirements_test_pre_commit.txt,target=requirements_test_pre_commit.txt \
uv pip install -r requirements.txt -r requirements_test.txt uv pip install -r requirements.txt -r requirements_test.txt
# Claude Code native install
RUN curl -fsSL https://claude.ai/install.sh | bash
WORKDIR /workspaces WORKDIR /workspaces
# Set the default shell to bash instead of sh # Set the default shell to bash instead of sh

View File

@@ -10,7 +10,6 @@ coverage:
target: auto target: auto
threshold: 1 threshold: 1
paths: paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py - homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py - homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py - homeassistant/components/*/device_condition.py
@@ -29,7 +28,6 @@ coverage:
target: 100 target: 100
threshold: 0 threshold: 0
paths: paths:
- homeassistant/components/*/backup.py
- homeassistant/components/*/config_flow.py - homeassistant/components/*/config_flow.py
- homeassistant/components/*/device_action.py - homeassistant/components/*/device_action.py
- homeassistant/components/*/device_condition.py - homeassistant/components/*/device_condition.py

View File

@@ -402,8 +402,6 @@ class AuthManager:
if user.is_owner: if user.is_owner:
raise ValueError("Unable to deactivate the owner") raise ValueError("Unable to deactivate the owner")
await self._store.async_deactivate_user(user) await self._store.async_deactivate_user(user)
for refresh_token in list(user.refresh_tokens.values()):
self.async_remove_refresh_token(refresh_token)
async def async_remove_credentials(self, credentials: models.Credentials) -> None: async def async_remove_credentials(self, credentials: models.Credentials) -> None:
"""Remove credentials.""" """Remove credentials."""

View File

@@ -7,7 +7,6 @@ from typing import Any, Final
from homeassistant.const import ( from homeassistant.const import (
EVENT_COMPONENT_LOADED, EVENT_COMPONENT_LOADED,
EVENT_CORE_CONFIG_UPDATE, EVENT_CORE_CONFIG_UPDATE,
EVENT_LABS_UPDATED,
EVENT_LOVELACE_UPDATED, EVENT_LOVELACE_UPDATED,
EVENT_PANELS_UPDATED, EVENT_PANELS_UPDATED,
EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
@@ -46,7 +45,6 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_STATE_CHANGED, EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED, EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED, EVENT_LABEL_REGISTRY_UPDATED,
EVENT_LABS_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED, EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED, EVENT_FLOOR_REGISTRY_UPDATED,
} }

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
import hashlib
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
@@ -39,6 +40,17 @@ class RestoreBackupFileContent:
restore_homeassistant: bool restore_homeassistant: bool
def password_to_key(password: str) -> bytes:
"""Generate a AES Key from password.
Matches the implementation in supervisor.backups.utils.password_to_key.
"""
key: bytes = password.encode()
for _ in range(100):
key = hashlib.sha256(key).digest()
return key[:16]
def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None: def restore_backup_file_content(config_dir: Path) -> RestoreBackupFileContent | None:
"""Return the contents of the restore backup file.""" """Return the contents of the restore backup file."""
instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE) instruction_path = config_dir.joinpath(RESTORE_BACKUP_FILE)
@@ -84,14 +96,15 @@ def _extract_backup(
"""Extract the backup file to the config directory.""" """Extract the backup file to the config directory."""
with ( with (
TemporaryDirectory() as tempdir, TemporaryDirectory() as tempdir,
securetar.SecureTarArchive( securetar.SecureTarFile(
restore_content.backup_file_path, restore_content.backup_file_path,
gzip=False,
mode="r", mode="r",
) as ostf, ) as ostf,
): ):
ostf.tar.extractall( ostf.extractall(
path=Path(tempdir, "extracted"), path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar), members=securetar.secure_path(ostf),
filter="fully_trusted", filter="fully_trusted",
) )
backup_meta_file = Path(tempdir, "extracted", "backup.json") backup_meta_file = Path(tempdir, "extracted", "backup.json")
@@ -113,7 +126,10 @@ def _extract_backup(
f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}", f"homeassistant.tar{'.gz' if backup_meta['compressed'] else ''}",
), ),
gzip=backup_meta["compressed"], gzip=backup_meta["compressed"],
password=restore_content.password, key=password_to_key(restore_content.password)
if restore_content.password is not None
else None,
mode="r",
) as istf: ) as istf:
istf.extractall( istf.extractall(
path=Path(tempdir, "homeassistant"), path=Path(tempdir, "homeassistant"),

View File

@@ -67,6 +67,8 @@ from .const import (
BASE_PLATFORMS, BASE_PLATFORMS,
FORMAT_DATETIME, FORMAT_DATETIME,
KEY_DATA_LOGGING as DATA_LOGGING, KEY_DATA_LOGGING as DATA_LOGGING,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
REQUIRED_NEXT_PYTHON_VER,
SIGNAL_BOOTSTRAP_INTEGRATIONS, SIGNAL_BOOTSTRAP_INTEGRATIONS,
) )
from .core_config import async_process_ha_core_config from .core_config import async_process_ha_core_config
@@ -210,7 +212,6 @@ DEFAULT_INTEGRATIONS = {
"analytics", # Needed for onboarding "analytics", # Needed for onboarding
"application_credentials", "application_credentials",
"backup", "backup",
"brands",
"frontend", "frontend",
"hardware", "hardware",
"labs", "labs",
@@ -515,6 +516,38 @@ async def async_from_config_dict(
stop = monotonic() stop = monotonic()
_LOGGER.info("Home Assistant initialized in %.2fs", stop - start) _LOGGER.info("Home Assistant initialized in %.2fs", stop - start)
if (
REQUIRED_NEXT_PYTHON_HA_RELEASE
and sys.version_info[:3] < REQUIRED_NEXT_PYTHON_VER
):
current_python_version = ".".join(str(x) for x in sys.version_info[:3])
required_python_version = ".".join(str(x) for x in REQUIRED_NEXT_PYTHON_VER[:2])
_LOGGER.warning(
(
"Support for the running Python version %s is deprecated and "
"will be removed in Home Assistant %s; "
"Please upgrade Python to %s"
),
current_python_version,
REQUIRED_NEXT_PYTHON_HA_RELEASE,
required_python_version,
)
issue_registry.async_create_issue(
hass,
core.DOMAIN,
f"python_version_{required_python_version}",
is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,
translation_key="python_version",
translation_placeholders={
"current_python_version": current_python_version,
"required_python_version": required_python_version,
"breaks_in_ha_version": REQUIRED_NEXT_PYTHON_HA_RELEASE,
},
)
return hass return hass
@@ -591,16 +624,13 @@ async def async_enable_logging(
if log_file is None: if log_file is None:
default_log_path = hass.config.path(ERROR_LOG_FILENAME) default_log_path = hass.config.path(ERROR_LOG_FILENAME)
if "SUPERVISOR" in os.environ and "HA_DUPLICATE_LOG_FILE" not in os.environ: if "SUPERVISOR" in os.environ:
_LOGGER.info("Running in Supervisor, not logging to file")
# Rename the default log file if it exists, since previous versions created # Rename the default log file if it exists, since previous versions created
# it even on Supervisor # it even on Supervisor
def rename_old_file() -> None: if os.path.isfile(default_log_path):
"""Rename old log file in executor.""" with contextlib.suppress(OSError):
if os.path.isfile(default_log_path): os.rename(default_log_path, f"{default_log_path}.old")
with contextlib.suppress(OSError):
os.rename(default_log_path, f"{default_log_path}.old")
await hass.async_add_executor_job(rename_old_file)
err_log_path = None err_log_path = None
else: else:
err_log_path = default_log_path err_log_path = default_log_path

View File

@@ -1,5 +0,0 @@
{
"domain": "american_standard",
"name": "American Standard",
"integrations": ["nexia", "trane"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "cloudflare",
"name": "Cloudflare",
"integrations": ["cloudflare", "cloudflare_r2"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "heatit",
"name": "Heatit",
"iot_standards": ["zwave"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "heiman",
"name": "Heiman",
"iot_standards": ["matter", "zigbee"]
}

View File

@@ -1,6 +1,5 @@
{ {
"domain": "leviton", "domain": "leviton",
"name": "Leviton", "name": "Leviton",
"integrations": ["decora_wifi"],
"iot_standards": ["zwave"] "iot_standards": ["zwave"]
} }

View File

@@ -13,7 +13,6 @@
"microsoft", "microsoft",
"msteams", "msteams",
"onedrive", "onedrive",
"onedrive_for_business",
"xbox" "xbox"
] ]
} }

View File

@@ -1,5 +0,0 @@
{
"domain": "powerfox",
"name": "Powerfox",
"integrations": ["powerfox", "powerfox_local"]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "trane",
"name": "Trane",
"integrations": ["nexia", "trane"]
}

View File

@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER from .const import CONF_POLLING, DOMAIN, LOGGER
from .services import async_setup_services from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name" ATTR_DEVICE_NAME = "device_name"
@@ -99,7 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except (AbodeException, ConnectTimeout, HTTPError) as ex: except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling) hass.data[DOMAIN] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -113,12 +113,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop) await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout) await hass.async_add_executor_job(hass.data[DOMAIN].abode.logout)
if logout_listener := hass.data[DOMAIN_DATA].logout_listener: hass.data[DOMAIN].logout_listener()
logout_listener() hass.data.pop(DOMAIN)
hass.data.pop(DOMAIN_DATA)
return unload_ok return unload_ok
@@ -128,16 +127,16 @@ async def setup_hass_events(hass: HomeAssistant) -> None:
def logout(event: Event) -> None: def logout(event: Event) -> None:
"""Logout of Abode.""" """Logout of Abode."""
if not hass.data[DOMAIN_DATA].polling: if not hass.data[DOMAIN].polling:
hass.data[DOMAIN_DATA].abode.events.stop() hass.data[DOMAIN].abode.events.stop()
hass.data[DOMAIN_DATA].abode.logout() hass.data[DOMAIN].abode.logout()
LOGGER.info("Logged out of Abode") LOGGER.info("Logged out of Abode")
if not hass.data[DOMAIN_DATA].polling: if not hass.data[DOMAIN].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start) await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start)
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once( hass.data[DOMAIN].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout EVENT_HOMEASSISTANT_STOP, logout
) )
@@ -179,6 +178,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
] ]
for event in events: for event in events:
hass.data[DOMAIN_DATA].abode.events.add_event_callback( hass.data[DOMAIN].abode.events.add_event_callback(
event, partial(event_callback, event) event, partial(event_callback, event)
) )

View File

@@ -13,7 +13,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
@@ -23,7 +24,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode alarm control panel device.""" """Set up Abode alarm control panel device."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))] [AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
) )

View File

@@ -15,7 +15,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
@@ -25,7 +26,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode binary sensor devices.""" """Set up Abode binary sensor devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
device_types = [ device_types = [
"connectivity", "connectivity",

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle from homeassistant.util import Throttle
from . import AbodeSystem from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER from .const import DOMAIN, LOGGER
from .entity import AbodeDevice from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -31,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode camera devices.""" """Set up Abode camera devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE) AbodeCamera(data, device, timeline.CAPTURE_IMAGE)

View File

@@ -64,7 +64,7 @@ class AbodeFlowHandler(ConfigFlow, domain=DOMAIN):
else: else:
errors = {"base": "cannot_connect"} errors = {"base": "cannot_connect"}
except ConnectTimeout, HTTPError: except (ConnectTimeout, HTTPError):
errors = {"base": "cannot_connect"} errors = {"base": "cannot_connect"}
if errors: if errors:

View File

@@ -1,19 +1,10 @@
"""Constants for the Abode Security System component.""" """Constants for the Abode Security System component."""
from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__) LOGGER = logging.getLogger(__package__)
DOMAIN = "abode" DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com" ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling" CONF_POLLING = "polling"

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode cover devices.""" """Set up Abode cover devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeCover(data, device) AbodeCover(data, device)

View File

@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from . import AbodeSystem from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA from .const import ATTRIBUTION, DOMAIN
class AbodeEntity(Entity): class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status, self._update_connection_status,
) )
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id) self.hass.data[DOMAIN].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates.""" """Unsubscribe from Abode connection status updates."""

View File

@@ -20,7 +20,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
@@ -30,7 +31,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode light devices.""" """Set up Abode light devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeLight(data, device) AbodeLight(data, device)
@@ -99,7 +100,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return _hs return _hs
@property @property
def color_mode(self) -> ColorMode: def color_mode(self) -> str | None:
"""Return the color mode of the light.""" """Return the color mode of the light."""
if self._device.is_dimmable and self._device.is_color_capable: if self._device.is_dimmable and self._device.is_color_capable:
if self.hs_color is not None: if self.hs_color is not None:
@@ -110,7 +111,7 @@ class AbodeLight(AbodeDevice, LightEntity):
return ColorMode.ONOFF return ColorMode.ONOFF
@property @property
def supported_color_modes(self) -> set[ColorMode]: def supported_color_modes(self) -> set[str] | None:
"""Flag supported color modes.""" """Flag supported color modes."""
if self._device.is_dimmable and self._device.is_color_capable: if self._device.is_dimmable and self._device.is_color_capable:
return {ColorMode.COLOR_TEMP, ColorMode.HS} return {ColorMode.COLOR_TEMP, ColorMode.HS}

View File

@@ -9,7 +9,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
@@ -19,7 +20,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode lock devices.""" """Set up Abode lock devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeLock(data, device) AbodeLock(data, device)

View File

@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem from . import AbodeSystem
from .const import DOMAIN_DATA from .const import DOMAIN
from .entity import AbodeDevice from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = { ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -66,7 +66,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode sensor devices.""" """Set up Abode sensor devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
async_add_entities( async_add_entities(
AbodeSensor(data, device, description) AbodeSensor(data, device, description)

View File

@@ -10,7 +10,11 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, DOMAIN_DATA, LOGGER from .const import DOMAIN, LOGGER
SERVICE_SETTINGS = "change_setting"
SERVICE_CAPTURE_IMAGE = "capture_image"
SERVICE_TRIGGER_AUTOMATION = "trigger_automation"
ATTR_SETTING = "setting" ATTR_SETTING = "setting"
ATTR_VALUE = "value" ATTR_VALUE = "value"
@@ -31,7 +35,7 @@ def _change_setting(call: ServiceCall) -> None:
value = call.data[ATTR_VALUE] value = call.data[ATTR_VALUE]
try: try:
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value) call.hass.data[DOMAIN].abode.set_setting(setting, value)
except AbodeException as ex: except AbodeException as ex:
LOGGER.warning(ex) LOGGER.warning(ex)
@@ -42,7 +46,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [ target_entities = [
entity_id entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids if entity_id in entity_ids
] ]
@@ -57,7 +61,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [ target_entities = [
entity_id entity_id
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids for entity_id in call.hass.data[DOMAIN].entity_ids
if entity_id in entity_ids if entity_id in entity_ids
] ]
@@ -71,13 +75,16 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services.""" """Home Assistant services."""
hass.services.async_register( hass.services.async_register(
DOMAIN, "change_setting", _change_setting, schema=CHANGE_SETTING_SCHEMA DOMAIN, SERVICE_SETTINGS, _change_setting, schema=CHANGE_SETTING_SCHEMA
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, "capture_image", _capture_image, schema=CAPTURE_IMAGE_SCHEMA DOMAIN, SERVICE_CAPTURE_IMAGE, _capture_image, schema=CAPTURE_IMAGE_SCHEMA
) )
hass.services.async_register( hass.services.async_register(
DOMAIN, "trigger_automation", _trigger_automation, schema=AUTOMATION_SCHEMA DOMAIN,
SERVICE_TRIGGER_AUTOMATION,
_trigger_automation,
schema=AUTOMATION_SCHEMA,
) )

View File

@@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN_DATA from . import AbodeSystem
from .const import DOMAIN
from .entity import AbodeAutomation, AbodeDevice from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"] DEVICE_TYPES = ["switch", "valve"]
@@ -24,7 +25,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up Abode switch devices.""" """Set up Abode switch devices."""
data = hass.data[DOMAIN_DATA] data: AbodeSystem = hass.data[DOMAIN]
entities: list[SwitchEntity] = [ entities: list[SwitchEntity] = [
AbodeSwitch(data, device) AbodeSwitch(data, device)

View File

@@ -7,7 +7,7 @@ import logging
from accuweather import AccuWeather from accuweather import AccuWeather
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_PLATFORM
from homeassistant.const import CONF_API_KEY, Platform from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
@@ -72,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AccuWeatherConfigEntry)
ent_reg = er.async_get(hass) ent_reg = er.async_get(hass)
for day in range(5): for day in range(5):
unique_id = f"{location_key}-ozone-{day}" unique_id = f"{location_key}-ozone-{day}"
if entity_id := ent_reg.async_get_entity_id(SENSOR_DOMAIN, DOMAIN, unique_id): if entity_id := ent_reg.async_get_entity_id(SENSOR_PLATFORM, DOMAIN, unique_id):
_LOGGER.debug("Removing ozone sensor entity %s", entity_id) _LOGGER.debug("Removing ozone sensor entity %s", entity_id)
ent_reg.async_remove(entity_id) ent_reg.async_remove(entity_id)

View File

@@ -43,7 +43,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
longitude=user_input[CONF_LONGITUDE], longitude=user_input[CONF_LONGITUDE],
) )
await accuweather.async_get_location() await accuweather.async_get_location()
except ApiError, ClientConnectorError, TimeoutError, ClientError: except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidApiKeyError: except InvalidApiKeyError:
errors[CONF_API_KEY] = "invalid_api_key" errors[CONF_API_KEY] = "invalid_api_key"
@@ -104,7 +104,7 @@ class AccuWeatherFlowHandler(ConfigFlow, domain=DOMAIN):
longitude=self._longitude, longitude=self._longitude,
) )
await accuweather.async_get_location() await accuweather.async_get_location()
except ApiError, ClientConnectorError, TimeoutError, ClientError: except (ApiError, ClientConnectorError, TimeoutError, ClientError):
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
except InvalidApiKeyError: except InvalidApiKeyError:
errors["base"] = "invalid_api_key" errors["base"] = "invalid_api_key"

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["accuweather"], "loggers": ["accuweather"],
"requirements": ["accuweather==5.1.0"] "requirements": ["accuweather==4.2.2"]
} }

View File

@@ -30,8 +30,6 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
) )
return { return {
"can_reach_server": system_health.async_check_can_reach_url( "can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT),
hass, str(ENDPOINT)
),
"remaining_requests": remaining_requests, "remaining_requests": remaining_requests,
} }

View File

@@ -9,16 +9,15 @@ from actron_neo_api import (
from homeassistant.const import CONF_API_TOKEN, Platform from homeassistant.const import CONF_API_TOKEN, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from .const import _LOGGER, DOMAIN from .const import _LOGGER
from .coordinator import ( from .coordinator import (
ActronAirConfigEntry, ActronAirConfigEntry,
ActronAirRuntimeData, ActronAirRuntimeData,
ActronAirSystemCoordinator, ActronAirSystemCoordinator,
) )
PLATFORMS = [Platform.CLIMATE, Platform.SWITCH] PLATFORM = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
@@ -30,13 +29,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
try: try:
systems = await api.get_ac_systems() systems = await api.get_ac_systems()
await api.update_status() await api.update_status()
except ActronAirAuthError as err: except ActronAirAuthError:
raise ConfigEntryAuthFailed( _LOGGER.error("Authentication error while setting up Actron Air integration")
translation_domain=DOMAIN, raise
translation_key="auth_error",
) from err
except ActronAirAPIError as err: except ActronAirAPIError as err:
raise ConfigEntryNotReady from err _LOGGER.error("API error while setting up Actron Air integration: %s", err)
raise
system_coordinators: dict[str, ActronAirSystemCoordinator] = {} system_coordinators: dict[str, ActronAirSystemCoordinator] = {}
for system in systems: for system in systems:
@@ -50,10 +48,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) ->
system_coordinators=system_coordinators, system_coordinators=system_coordinators,
) )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORM)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ActronAirConfigEntry) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORM)

View File

@@ -15,10 +15,12 @@ from homeassistant.components.climate import (
) )
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, ActronAirZoneEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -54,7 +56,8 @@ async def async_setup_entry(
for coordinator in system_coordinators.values(): for coordinator in system_coordinators.values():
status = coordinator.data status = coordinator.data
entities.append(ActronSystemClimate(coordinator)) name = status.ac_system.system_name
entities.append(ActronSystemClimate(coordinator, name))
entities.extend( entities.extend(
ActronZoneClimate(coordinator, zone) ActronZoneClimate(coordinator, zone)
@@ -65,9 +68,10 @@ async def async_setup_entry(
async_add_entities(entities) async_add_entities(entities)
class ActronAirClimateEntity(ClimateEntity): class BaseClimateEntity(CoordinatorEntity[ActronAirSystemCoordinator], ClimateEntity):
"""Base class for Actron Air climate entities.""" """Base class for Actron Air climate entities."""
_attr_has_entity_name = True
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_supported_features = ( _attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE ClimateEntityFeature.TARGET_TEMPERATURE
@@ -79,17 +83,43 @@ class ActronAirClimateEntity(ClimateEntity):
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values()) _attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values()) _attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
name: str,
) -> None:
"""Initialize an Actron Air unit."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
class ActronSystemClimate(BaseClimateEntity):
"""Representation of the Actron Air system.""" """Representation of the Actron Air system."""
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
| ClimateEntityFeature.TURN_ON
| ClimateEntityFeature.TURN_OFF
)
def __init__( def __init__(
self, self,
coordinator: ActronAirSystemCoordinator, coordinator: ActronAirSystemCoordinator,
name: str,
) -> None: ) -> None:
"""Initialize an Actron Air unit.""" """Initialize an Actron Air unit."""
super().__init__(coordinator) super().__init__(coordinator, name)
self._attr_unique_id = self._serial_number serial_number = coordinator.serial_number
self._attr_unique_id = serial_number
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, serial_number)},
name=self._status.ac_system.system_name,
manufacturer="Actron Air",
model_id=self._status.ac_system.master_wc_model,
sw_version=self._status.ac_system.master_wc_firmware_version,
serial_number=serial_number,
)
@property @property
def min_temp(self) -> float: def min_temp(self) -> float:
@@ -118,7 +148,7 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
@property @property
def fan_mode(self) -> str | None: def fan_mode(self) -> str | None:
"""Return the current fan mode.""" """Return the current fan mode."""
fan_mode = self._status.user_aircon_settings.base_fan_mode fan_mode = self._status.user_aircon_settings.fan_mode
return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode) return FAN_MODE_MAPPING_ACTRONAIR_TO_HA.get(fan_mode)
@property @property
@@ -136,26 +166,23 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
"""Return the target temperature.""" """Return the target temperature."""
return self._status.user_aircon_settings.temperature_setpoint_cool_c return self._status.user_aircon_settings.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_fan_mode(self, fan_mode: str) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set a new fan mode.""" """Set a new fan mode."""
api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode) api_fan_mode = FAN_MODE_MAPPING_HA_TO_ACTRONAIR.get(fan_mode.lower())
await self._status.user_aircon_settings.set_fan_mode(api_fan_mode) await self._status.user_aircon_settings.set_fan_mode(api_fan_mode)
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode.""" """Set the HVAC mode."""
ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode) ac_mode = HVAC_MODE_MAPPING_HA_TO_ACTRONAIR.get(hvac_mode)
await self._status.ac_system.set_system_mode(ac_mode) await self._status.ac_system.set_system_mode(ac_mode)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature.""" """Set the temperature."""
temp = kwargs.get(ATTR_TEMPERATURE) temp = kwargs.get(ATTR_TEMPERATURE)
await self._status.user_aircon_settings.set_temperature(temperature=temp) await self._status.user_aircon_settings.set_temperature(temperature=temp)
class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity): class ActronZoneClimate(BaseClimateEntity):
"""Representation of a zone within the Actron Air system.""" """Representation of a zone within the Actron Air system."""
_attr_supported_features = ( _attr_supported_features = (
@@ -170,8 +197,18 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
zone: ActronAirZone, zone: ActronAirZone,
) -> None: ) -> None:
"""Initialize an Actron Air unit.""" """Initialize an Actron Air unit."""
super().__init__(coordinator, zone) super().__init__(coordinator, zone.title)
self._attr_unique_id: str = self._zone_identifier serial_number = coordinator.serial_number
self._zone_id: int = zone.zone_id
self._attr_unique_id: str = f"{serial_number}_zone_{zone.zone_id}"
self._attr_device_info: DeviceInfo = DeviceInfo(
identifiers={(DOMAIN, self._attr_unique_id)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, serial_number),
)
@property @property
def min_temp(self) -> float: def min_temp(self) -> float:
@@ -212,13 +249,11 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
"""Return the target temperature.""" """Return the target temperature."""
return self._zone.temperature_setpoint_cool_c return self._zone.temperature_setpoint_cool_c
@handle_actron_api_errors
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set the HVAC mode.""" """Set the HVAC mode."""
is_enabled = hvac_mode != HVACMode.OFF is_enabled = hvac_mode != HVACMode.OFF
await self._zone.enable(is_enabled) await self._zone.enable(is_enabled)
@handle_actron_api_errors
async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the temperature.""" """Set the temperature."""
await self._zone.set_temperature(temperature=kwargs.get(ATTR_TEMPERATURE)) await self._zone.set_temperature(temperature=kwargs["temperature"])

View File

@@ -1,12 +1,11 @@
"""Setup config flow for Actron Air integration.""" """Setup config flow for Actron Air integration."""
import asyncio import asyncio
from collections.abc import Mapping
from typing import Any from typing import Any
from actron_neo_api import ActronAirAPI, ActronAirAuthError from actron_neo_api import ActronAirAPI, ActronAirAuthError
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN from homeassistant.const import CONF_API_TOKEN
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
@@ -96,16 +95,8 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id = str(user_data["id"]) unique_id = str(user_data["id"])
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
# Check if this is a reauth flow
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_updates={CONF_API_TOKEN: self._api.refresh_token_value},
)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
return self.async_create_entry( return self.async_create_entry(
title=user_data["email"], title=user_data["email"],
data={CONF_API_TOKEN: self._api.refresh_token_value}, data={CONF_API_TOKEN: self._api.refresh_token_value},
@@ -123,21 +114,6 @@ class ActronAirConfigFlow(ConfigFlow, domain=DOMAIN):
del self.login_task del self.login_task
return await self.async_step_user() return await self.async_step_user()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication request."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth dialog."""
if user_input is not None:
return await self.async_step_user()
return self.async_show_form(step_id="reauth_confirm")
async def async_step_connection_error( async def async_step_connection_error(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:

View File

@@ -5,24 +5,16 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from actron_neo_api import ( from actron_neo_api import ActronAirACSystem, ActronAirAPI, ActronAirStatus
ActronAirACSystem,
ActronAirAPI,
ActronAirAPIError,
ActronAirAuthError,
ActronAirStatus,
)
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from .const import _LOGGER, DOMAIN from .const import _LOGGER
SCAN_INTERVAL = timedelta(seconds=30) STALE_DEVICE_TIMEOUT = timedelta(hours=24)
STALE_DEVICE_TIMEOUT = timedelta(minutes=5)
ERROR_NO_SYSTEMS_FOUND = "no_systems_found" ERROR_NO_SYSTEMS_FOUND = "no_systems_found"
ERROR_UNKNOWN = "unknown_error" ERROR_UNKNOWN = "unknown_error"
@@ -37,6 +29,9 @@ class ActronAirRuntimeData:
type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData] type ActronAirConfigEntry = ConfigEntry[ActronAirRuntimeData]
AUTH_ERROR_THRESHOLD = 3
SCAN_INTERVAL = timedelta(seconds=30)
class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]): class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
"""System coordinator for Actron Air integration.""" """System coordinator for Actron Air integration."""
@@ -64,20 +59,7 @@ class ActronAirSystemCoordinator(DataUpdateCoordinator[ActronAirACSystem]):
async def _async_update_data(self) -> ActronAirStatus: async def _async_update_data(self) -> ActronAirStatus:
"""Fetch updates and merge incremental changes into the full state.""" """Fetch updates and merge incremental changes into the full state."""
try: await self.api.update_status()
await self.api.update_status()
except ActronAirAuthError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except ActronAirAPIError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_error",
translation_placeholders={"error": repr(err)},
) from err
self.status = self.api.state_manager.get_status(self.serial_number) self.status = self.api.state_manager.get_status(self.serial_number)
self.last_seen = dt_util.utcnow() self.last_seen = dt_util.utcnow()
return self.status return self.status

View File

@@ -1,88 +0,0 @@
"""Base entity classes for Actron Air integration."""
from collections.abc import Callable, Coroutine
from functools import wraps
from typing import Any, Concatenate
from actron_neo_api import ActronAirAPIError, ActronAirZone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ActronAirSystemCoordinator
def handle_actron_api_errors[_EntityT: ActronAirEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate Actron Air API calls to handle ActronAirAPIError exceptions."""
@wraps(func)
async def wrapper(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
"""Wrap API calls with exception handling."""
try:
await func(self, *args, **kwargs)
except ActronAirAPIError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"error": str(err)},
) from err
return wrapper
class ActronAirEntity(CoordinatorEntity[ActronAirSystemCoordinator]):
"""Base class for Actron Air entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._serial_number = coordinator.serial_number
@property
def available(self) -> bool:
"""Return True if entity is available."""
return not self.coordinator.is_device_stale()
class ActronAirAcEntity(ActronAirEntity):
"""Base class for Actron Air entities."""
def __init__(self, coordinator: ActronAirSystemCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._serial_number)},
name=coordinator.data.ac_system.system_name,
manufacturer="Actron Air",
model_id=coordinator.data.ac_system.master_wc_model,
sw_version=coordinator.data.ac_system.master_wc_firmware_version,
serial_number=self._serial_number,
)
class ActronAirZoneEntity(ActronAirEntity):
"""Base class for Actron Air zone entities."""
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
zone: ActronAirZone,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._zone_id: int = zone.zone_id
self._zone_identifier = f"{self._serial_number}_zone_{zone.zone_id}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._zone_identifier)},
name=zone.title,
manufacturer="Actron Air",
model="Zone",
suggested_area=zone.title,
via_device=(DOMAIN, self._serial_number),
)

View File

@@ -1,30 +0,0 @@
{
"entity": {
"switch": {
"away_mode": {
"default": "mdi:home-export-outline",
"state": {
"off": "mdi:home-import-outline"
}
},
"continuous_fan": {
"default": "mdi:fan",
"state": {
"off": "mdi:fan-off"
}
},
"quiet_mode": {
"default": "mdi:volume-low",
"state": {
"off": "mdi:volume-high"
}
},
"turbo_mode": {
"default": "mdi:fan-plus",
"state": {
"off": "mdi:fan"
}
}
}
}
}

View File

@@ -10,8 +10,7 @@
} }
], ],
"documentation": "https://www.home-assistant.io/integrations/actron_air", "documentation": "https://www.home-assistant.io/integrations/actron_air",
"integration_type": "hub",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["actron-neo-api==0.4.1"] "requirements": ["actron-neo-api==0.1.87"]
} }

View File

@@ -26,7 +26,7 @@ rules:
unique-config-entry: done unique-config-entry: done
# Silver # Silver
action-exceptions: done action-exceptions: todo
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: docs-configuration-parameters:
status: exempt status: exempt
@@ -36,7 +36,7 @@ rules:
integration-owner: done integration-owner: done
log-when-unavailable: done log-when-unavailable: done
parallel-updates: done parallel-updates: done
reauthentication-flow: done reauthentication-flow: todo
test-coverage: todo test-coverage: todo
# Gold # Gold

View File

@@ -2,12 +2,10 @@
"config": { "config": {
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"oauth2_error": "Failed to start authentication flow", "oauth2_error": "Failed to start OAuth2 flow"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You must reauthenticate with the same Actron Air account that was originally configured."
}, },
"error": { "error": {
"oauth2_error": "Failed to start authentication flow. Please try again later." "oauth2_error": "Failed to start OAuth2 flow. Please try again later."
}, },
"progress": { "progress": {
"wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes." "wait_for_authorization": "To authenticate, open the following URL and login at Actron Air:\n{verification_uri}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{user_code}```\n\n\nThe login attempt will time out after {expires_minutes} minutes."
@@ -18,45 +16,14 @@
"description": "Failed to connect to Actron Air. Please check your internet connection and try again.", "description": "Failed to connect to Actron Air. Please check your internet connection and try again.",
"title": "Connection error" "title": "Connection error"
}, },
"reauth_confirm": {
"description": "Your Actron Air authentication has expired. Select continue to reauthenticate with your Actron Air account. You will be prompted to log in again to restore the connection.",
"title": "Authentication expired"
},
"timeout": { "timeout": {
"data": {}, "data": {},
"description": "The authentication process timed out. Please try again.", "description": "The authorization process timed out. Please try again.",
"title": "Authentication timeout" "title": "Authorization timeout"
}, },
"user": { "user": {
"title": "Actron Air Authentication" "title": "Actron Air OAuth2 Authorization"
} }
} }
},
"entity": {
"switch": {
"away_mode": {
"name": "Away mode"
},
"continuous_fan": {
"name": "Continuous fan"
},
"quiet_mode": {
"name": "Quiet mode"
},
"turbo_mode": {
"name": "Turbo mode"
}
}
},
"exceptions": {
"api_error": {
"message": "Failed to communicate with Actron Air device: {error}"
},
"auth_error": {
"message": "Authentication failed, please reauthenticate"
},
"update_error": {
"message": "An error occurred while retrieving data from the Actron Air API: {error}"
}
} }
} }

View File

@@ -1,116 +0,0 @@
"""Switch platform for Actron Air integration."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ActronAirConfigEntry, ActronAirSystemCoordinator
from .entity import ActronAirAcEntity, handle_actron_api_errors
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ActronAirSwitchEntityDescription(SwitchEntityDescription):
"""Class describing Actron Air switch entities."""
is_on_fn: Callable[[ActronAirSystemCoordinator], bool]
set_fn: Callable[[ActronAirSystemCoordinator, bool], Awaitable[None]]
is_supported_fn: Callable[[ActronAirSystemCoordinator], bool] = lambda _: True
SWITCHES: tuple[ActronAirSwitchEntityDescription, ...] = (
ActronAirSwitchEntityDescription(
key="away_mode",
translation_key="away_mode",
is_on_fn=lambda coordinator: coordinator.data.user_aircon_settings.away_mode,
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_away_mode(enabled)
),
),
ActronAirSwitchEntityDescription(
key="continuous_fan",
translation_key="continuous_fan",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.continuous_fan_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_continuous_mode(enabled)
),
),
ActronAirSwitchEntityDescription(
key="quiet_mode",
translation_key="quiet_mode",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.quiet_mode_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_quiet_mode(enabled)
),
),
ActronAirSwitchEntityDescription(
key="turbo_mode",
translation_key="turbo_mode",
is_on_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.turbo_enabled
),
set_fn=lambda coordinator, enabled: (
coordinator.data.user_aircon_settings.set_turbo_mode(enabled)
),
is_supported_fn=lambda coordinator: (
coordinator.data.user_aircon_settings.turbo_supported
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ActronAirConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Actron Air switch entities."""
system_coordinators = entry.runtime_data.system_coordinators
async_add_entities(
ActronAirSwitch(coordinator, description)
for coordinator in system_coordinators.values()
for description in SWITCHES
if description.is_supported_fn(coordinator)
)
class ActronAirSwitch(ActronAirAcEntity, SwitchEntity):
"""Actron Air switch."""
_attr_entity_category = EntityCategory.CONFIG
entity_description: ActronAirSwitchEntityDescription
def __init__(
self,
coordinator: ActronAirSystemCoordinator,
description: ActronAirSwitchEntityDescription,
) -> None:
"""Initialize the switch."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.serial_number}_{description.key}"
@property
def is_on(self) -> bool:
"""Return true if the switch is on."""
return self.entity_description.is_on_fn(self.coordinator)
@handle_actron_api_errors
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.coordinator, True)
@handle_actron_api_errors
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.coordinator, False)

View File

@@ -87,7 +87,7 @@ class AdaxConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=data_schema, data_schema=data_schema,
) )
wifi_ssid = user_input[WIFI_SSID] wifi_ssid = user_input[WIFI_SSID].replace(" ", "")
wifi_pswd = user_input[WIFI_PSWD].replace(" ", "") wifi_pswd = user_input[WIFI_PSWD].replace(" ", "")
configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd) configurator = adax_local.AdaxConfig(wifi_ssid, wifi_pswd)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/adax", "documentation": "https://www.home-assistant.io/integrations/adax",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["adax", "adax_local"], "loggers": ["adax", "adax_local"],
"requirements": ["adax==0.4.0", "Adax-local==0.3.0"] "requirements": ["adax==0.4.0", "Adax-local==0.2.0"]
} }

View File

@@ -20,10 +20,9 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_FORCE, CONF_FORCE,
@@ -46,7 +45,6 @@ SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean} {vol.Optional(CONF_FORCE, default=False): cv.boolean}
) )
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE] PLATFORMS = [Platform.SENSOR, Platform.SWITCH, Platform.UPDATE]
type AdGuardConfigEntry = ConfigEntry[AdGuardData] type AdGuardConfigEntry = ConfigEntry[AdGuardData]
@@ -59,69 +57,6 @@ class AdGuardData:
version: str version: str
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
def _get_adguard_instances(hass: HomeAssistant) -> list[AdGuardHome]:
"""Get the AdGuardHome instances."""
entries: list[AdGuardConfigEntry] = hass.config_entries.async_loaded_entries(
DOMAIN
)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN, translation_key="config_entry_not_loaded"
)
return [entry.runtime_data.client for entry in entries]
async def add_url(call: ServiceCall) -> None:
"""Service call to add a new filter subscription to AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.add_url(
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
)
async def remove_url(call: ServiceCall) -> None:
"""Service call to remove a filter subscription from AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
async def enable_url(call: ServiceCall) -> None:
"""Service call to enable a filter subscription in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
async def disable_url(call: ServiceCall) -> None:
"""Service call to disable a filter subscription in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.disable_url(
allowlist=False, url=call.data[CONF_URL]
)
async def refresh(call: ServiceCall) -> None:
"""Service call to refresh the filter subscriptions in AdGuard Home."""
for adguard in _get_adguard_instances(call.hass):
await adguard.filtering.refresh(
allowlist=False, force=call.data[CONF_FORCE]
)
hass.services.async_register(
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Set up AdGuard Home from a config entry.""" """Set up AdGuard Home from a config entry."""
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
@@ -144,9 +79,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def add_url(call: ServiceCall) -> None:
"""Service call to add a new filter subscription to AdGuard Home."""
await adguard.filtering.add_url(
allowlist=False, name=call.data[CONF_NAME], url=call.data[CONF_URL]
)
async def remove_url(call: ServiceCall) -> None:
"""Service call to remove a filter subscription from AdGuard Home."""
await adguard.filtering.remove_url(allowlist=False, url=call.data[CONF_URL])
async def enable_url(call: ServiceCall) -> None:
"""Service call to enable a filter subscription in AdGuard Home."""
await adguard.filtering.enable_url(allowlist=False, url=call.data[CONF_URL])
async def disable_url(call: ServiceCall) -> None:
"""Service call to disable a filter subscription in AdGuard Home."""
await adguard.filtering.disable_url(allowlist=False, url=call.data[CONF_URL])
async def refresh(call: ServiceCall) -> None:
"""Service call to refresh the filter subscriptions in AdGuard Home."""
await adguard.filtering.refresh(allowlist=False, force=call.data[CONF_FORCE])
hass.services.async_register(
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Unload AdGuard Home config entry.""" """Unload AdGuard Home config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of AdGuard, deregister any services
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
return unload_ok

View File

@@ -107,7 +107,7 @@ class AdGuardHomeFlowHandler(ConfigFlow, domain=DOMAIN):
async def async_step_hassio( async def async_step_hassio(
self, discovery_info: HassioServiceInfo self, discovery_info: HassioServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Prepare configuration for a Hass.io AdGuard Home app. """Prepare configuration for a Hass.io AdGuard Home add-on.
This flow is triggered by the discovery component. This flow is triggered by the discovery component.
""" """

View File

@@ -52,7 +52,7 @@ class AdGuardHomeEntity(Entity):
def device_info(self) -> DeviceInfo: def device_info(self) -> DeviceInfo:
"""Return device information about this AdGuard Home instance.""" """Return device information about this AdGuard Home instance."""
if self._entry.source == SOURCE_HASSIO: if self._entry.source == SOURCE_HASSIO:
config_url = "homeassistant://app/a0d7b954_adguard" config_url = "homeassistant://hassio/ingress/a0d7b954_adguard"
elif self.adguard.tls: elif self.adguard.tls:
config_url = f"https://{self.adguard.host}:{self.adguard.port}" config_url = f"https://{self.adguard.host}:{self.adguard.port}"
else: else:

View File

@@ -9,8 +9,8 @@
}, },
"step": { "step": {
"hassio_confirm": { "hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the app: {addon}?", "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the add-on: {addon}?",
"title": "AdGuard Home via Home Assistant app" "title": "AdGuard Home via Home Assistant add-on"
}, },
"user": { "user": {
"data": { "data": {
@@ -76,11 +76,6 @@
} }
} }
}, },
"exceptions": {
"config_entry_not_loaded": {
"message": "Config entry not loaded."
}
},
"services": { "services": {
"add_url": { "add_url": {
"description": "Adds a new filter subscription to AdGuard Home.", "description": "Adds a new filter subscription to AdGuard Home.",

View File

@@ -9,13 +9,9 @@ import voluptuous as vol
from homeassistant.components.light import ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
ColorMode, ColorMode,
LightEntity, LightEntity,
filter_supported_color_modes,
) )
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@@ -28,20 +24,13 @@ from .entity import AdsEntity
from .hub import AdsHub from .hub import AdsHub
CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness" CONF_ADS_VAR_BRIGHTNESS = "adsvar_brightness"
CONF_ADS_VAR_COLOR_TEMP_KELVIN = "adsvar_color_temp_kelvin"
CONF_MIN_COLOR_TEMP_KELVIN = "min_color_temp_kelvin"
CONF_MAX_COLOR_TEMP_KELVIN = "max_color_temp_kelvin"
STATE_KEY_BRIGHTNESS = "brightness" STATE_KEY_BRIGHTNESS = "brightness"
STATE_KEY_COLOR_TEMP_KELVIN = "color_temp_kelvin"
DEFAULT_NAME = "ADS Light" DEFAULT_NAME = "ADS Light"
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{ {
vol.Required(CONF_ADS_VAR): cv.string, vol.Required(CONF_ADS_VAR): cv.string,
vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string, vol.Optional(CONF_ADS_VAR_BRIGHTNESS): cv.string,
vol.Optional(CONF_ADS_VAR_COLOR_TEMP_KELVIN): cv.string,
vol.Optional(CONF_MIN_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_MAX_COLOR_TEMP_KELVIN): cv.positive_int,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
} }
) )
@@ -58,24 +47,9 @@ def setup_platform(
ads_var_enable: str = config[CONF_ADS_VAR] ads_var_enable: str = config[CONF_ADS_VAR]
ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS) ads_var_brightness: str | None = config.get(CONF_ADS_VAR_BRIGHTNESS)
ads_var_color_temp_kelvin: str | None = config.get(CONF_ADS_VAR_COLOR_TEMP_KELVIN)
min_color_temp_kelvin: int | None = config.get(CONF_MIN_COLOR_TEMP_KELVIN)
max_color_temp_kelvin: int | None = config.get(CONF_MAX_COLOR_TEMP_KELVIN)
name: str = config[CONF_NAME] name: str = config[CONF_NAME]
add_entities( add_entities([AdsLight(ads_hub, ads_var_enable, ads_var_brightness, name)])
[
AdsLight(
ads_hub,
ads_var_enable,
ads_var_brightness,
ads_var_color_temp_kelvin,
min_color_temp_kelvin,
max_color_temp_kelvin,
name,
)
]
)
class AdsLight(AdsEntity, LightEntity): class AdsLight(AdsEntity, LightEntity):
@@ -86,40 +60,18 @@ class AdsLight(AdsEntity, LightEntity):
ads_hub: AdsHub, ads_hub: AdsHub,
ads_var_enable: str, ads_var_enable: str,
ads_var_brightness: str | None, ads_var_brightness: str | None,
ads_var_color_temp_kelvin: str | None,
min_color_temp_kelvin: int | None,
max_color_temp_kelvin: int | None,
name: str, name: str,
) -> None: ) -> None:
"""Initialize AdsLight entity.""" """Initialize AdsLight entity."""
super().__init__(ads_hub, name, ads_var_enable) super().__init__(ads_hub, name, ads_var_enable)
self._state_dict[STATE_KEY_BRIGHTNESS] = None self._state_dict[STATE_KEY_BRIGHTNESS] = None
self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN] = None
self._ads_var_brightness = ads_var_brightness self._ads_var_brightness = ads_var_brightness
self._ads_var_color_temp_kelvin = ads_var_color_temp_kelvin
# Determine supported color modes
color_modes = {ColorMode.ONOFF}
if ads_var_brightness is not None: if ads_var_brightness is not None:
color_modes.add(ColorMode.BRIGHTNESS) self._attr_color_mode = ColorMode.BRIGHTNESS
if ads_var_color_temp_kelvin is not None: self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
color_modes.add(ColorMode.COLOR_TEMP) else:
self._attr_color_mode = ColorMode.ONOFF
self._attr_supported_color_modes = filter_supported_color_modes(color_modes) self._attr_supported_color_modes = {ColorMode.ONOFF}
self._attr_color_mode = next(iter(self._attr_supported_color_modes))
# Set color temperature range (static config values take precedence over defaults)
if ads_var_color_temp_kelvin is not None:
self._attr_min_color_temp_kelvin = (
min_color_temp_kelvin
if min_color_temp_kelvin is not None
else DEFAULT_MIN_KELVIN
)
self._attr_max_color_temp_kelvin = (
max_color_temp_kelvin
if max_color_temp_kelvin is not None
else DEFAULT_MAX_KELVIN
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Register device notification.""" """Register device notification."""
@@ -132,23 +84,11 @@ class AdsLight(AdsEntity, LightEntity):
STATE_KEY_BRIGHTNESS, STATE_KEY_BRIGHTNESS,
) )
if self._ads_var_color_temp_kelvin is not None:
await self.async_initialize_device(
self._ads_var_color_temp_kelvin,
pyads.PLCTYPE_UINT,
STATE_KEY_COLOR_TEMP_KELVIN,
)
@property @property
def brightness(self) -> int | None: def brightness(self) -> int | None:
"""Return the brightness of the light (0..255).""" """Return the brightness of the light (0..255)."""
return self._state_dict[STATE_KEY_BRIGHTNESS] return self._state_dict[STATE_KEY_BRIGHTNESS]
@property
def color_temp_kelvin(self) -> int | None:
"""Return the color temperature in Kelvin."""
return self._state_dict[STATE_KEY_COLOR_TEMP_KELVIN]
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return True if the entity is on.""" """Return True if the entity is on."""
@@ -157,8 +97,6 @@ class AdsLight(AdsEntity, LightEntity):
def turn_on(self, **kwargs: Any) -> None: def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on or set a specific dimmer value.""" """Turn the light on or set a specific dimmer value."""
brightness = kwargs.get(ATTR_BRIGHTNESS) brightness = kwargs.get(ATTR_BRIGHTNESS)
color_temp = kwargs.get(ATTR_COLOR_TEMP_KELVIN)
self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL) self._ads_hub.write_by_name(self._ads_var, True, pyads.PLCTYPE_BOOL)
if self._ads_var_brightness is not None and brightness is not None: if self._ads_var_brightness is not None and brightness is not None:
@@ -166,11 +104,6 @@ class AdsLight(AdsEntity, LightEntity):
self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT self._ads_var_brightness, brightness, pyads.PLCTYPE_UINT
) )
if self._ads_var_color_temp_kelvin is not None and color_temp is not None:
self._ads_hub.write_by_name(
self._ads_var_color_temp_kelvin, color_temp, pyads.PLCTYPE_UINT
)
def turn_off(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None:
"""Turn the light off.""" """Turn the light off."""
self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL) self._ads_hub.write_by_name(self._ads_var, False, pyads.PLCTYPE_BOOL)

View File

@@ -1,17 +1,23 @@
"""Advantage Air climate integration.""" """Advantage Air climate integration."""
from advantage_air import advantage_air from datetime import timedelta
import logging
from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import ADVANTAGE_AIR_RETRY, DOMAIN from .const import ADVANTAGE_AIR_RETRY
from .coordinator import AdvantageAirCoordinator, AdvantageAirDataConfigEntry from .models import AdvantageAirData
from .services import async_setup_services
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirData]
ADVANTAGE_AIR_SYNC_INTERVAL = 15
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
@@ -23,13 +29,8 @@ PLATFORMS = [
Platform.UPDATE, Platform.UPDATE,
] ]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__)
REQUEST_REFRESH_DELAY = 0.5
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
async_setup_services(hass)
return True
async def async_setup_entry( async def async_setup_entry(
@@ -45,10 +46,27 @@ async def async_setup_entry(
retry=ADVANTAGE_AIR_RETRY, retry=ADVANTAGE_AIR_RETRY,
) )
coordinator = AdvantageAirCoordinator(hass, entry, api) async def async_get():
try:
return await api.async_get()
except ApiError as err:
raise UpdateFailed(err) from err
coordinator = DataUpdateCoordinator(
hass,
_LOGGER,
config_entry=entry,
name="Advantage Air",
update_method=async_get,
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator entry.runtime_data = AdvantageAirData(coordinator, api)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -24,23 +24,19 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir Binary Sensor platform.""" """Set up AdvantageAir Binary Sensor platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[BinarySensorEntity] = [] entities: list[BinarySensorEntity] = []
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items(): for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirFilter(coordinator, ac_key)) entities.append(AdvantageAirFilter(instance, ac_key))
for zone_key, zone in ac_device["zones"].items(): for zone_key, zone in ac_device["zones"].items():
# Only add motion sensor when motion is enabled # Only add motion sensor when motion is enabled
if zone["motionConfig"] >= 2: if zone["motionConfig"] >= 2:
entities.append( entities.append(AdvantageAirZoneMotion(instance, ac_key, zone_key))
AdvantageAirZoneMotion(coordinator, ac_key, zone_key)
)
# Only add MyZone if it is available # Only add MyZone if it is available
if zone["type"] != 0: if zone["type"] != 0:
entities.append( entities.append(AdvantageAirZoneMyZone(instance, ac_key, zone_key))
AdvantageAirZoneMyZone(coordinator, ac_key, zone_key)
)
async_add_entities(entities) async_add_entities(entities)
@@ -51,9 +47,9 @@ class AdvantageAirFilter(AdvantageAirAcEntity, BinarySensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_name = "Filter" _attr_name = "Filter"
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air Filter sensor.""" """Initialize an Advantage Air Filter sensor."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_unique_id += "-filter" self._attr_unique_id += "-filter"
@property @property
@@ -67,11 +63,9 @@ class AdvantageAirZoneMotion(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOTION _attr_device_class = BinarySensorDeviceClass.MOTION
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone Motion sensor.""" """Initialize an Advantage Air Zone Motion sensor."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} motion" self._attr_name = f"{self._zone['name']} motion"
self._attr_unique_id += "-motion" self._attr_unique_id += "-motion"
@@ -87,11 +81,9 @@ class AdvantageAirZoneMyZone(AdvantageAirZoneEntity, BinarySensorEntity):
_attr_entity_registry_enabled_default = False _attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone MyZone sensor.""" """Initialize an Advantage Air Zone MyZone sensor."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} myZone" self._attr_name = f"{self._zone['name']} myZone"
self._attr_unique_id += "-myzone" self._attr_unique_id += "-myzone"

View File

@@ -31,8 +31,8 @@ from .const import (
ADVANTAGE_AIR_STATE_ON, ADVANTAGE_AIR_STATE_ON,
ADVANTAGE_AIR_STATE_OPEN, ADVANTAGE_AIR_STATE_OPEN,
) )
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_HVAC_MODES = { ADVANTAGE_AIR_HVAC_MODES = {
"heat": HVACMode.HEAT, "heat": HVACMode.HEAT,
@@ -90,16 +90,16 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir climate platform.""" """Set up AdvantageAir climate platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[ClimateEntity] = [] entities: list[ClimateEntity] = []
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items(): for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirAC(coordinator, ac_key)) entities.append(AdvantageAirAC(instance, ac_key))
for zone_key, zone in ac_device["zones"].items(): for zone_key, zone in ac_device["zones"].items():
# Only add zone climate control when zone is in temperature control # Only add zone climate control when zone is in temperature control
if zone["type"] > 0: if zone["type"] > 0:
entities.append(AdvantageAirZone(coordinator, ac_key, zone_key)) entities.append(AdvantageAirZone(instance, ac_key, zone_key))
async_add_entities(entities) async_add_entities(entities)
@@ -114,9 +114,9 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
_attr_name = None _attr_name = None
_support_preset = ClimateEntityFeature(0) _support_preset = ClimateEntityFeature(0)
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an AdvantageAir AC unit.""" """Initialize an AdvantageAir AC unit."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE] self._attr_preset_modes = [ADVANTAGE_AIR_MYZONE]
@@ -282,11 +282,9 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
_attr_max_temp = 32 _attr_max_temp = 32
_attr_min_temp = 16 _attr_min_temp = 16
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an AdvantageAir Zone control.""" """Initialize an AdvantageAir Zone control."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"] self._attr_name = self._zone["name"]
@property @property

View File

@@ -1,59 +0,0 @@
"""Coordinator for the Advantage Air integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from typing import Any
from advantage_air import ApiError, advantage_air
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
ADVANTAGE_AIR_SYNC_INTERVAL = 15
REQUEST_REFRESH_DELAY = 0.5
_LOGGER = logging.getLogger(__name__)
type AdvantageAirDataConfigEntry = ConfigEntry[AdvantageAirCoordinator]
class AdvantageAirCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Advantage Air coordinator."""
config_entry: AdvantageAirDataConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
api: advantage_air,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name="Advantage Air",
update_interval=timedelta(seconds=ADVANTAGE_AIR_SYNC_INTERVAL),
request_refresh_debouncer=Debouncer(
hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False
),
)
self.api = api
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch data from the API."""
try:
return await self.api.async_get()
except ApiError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err

View File

@@ -13,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity from .entity import AdvantageAirThingEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -26,24 +26,24 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir cover platform.""" """Set up AdvantageAir cover platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[CoverEntity] = [] entities: list[CoverEntity] = []
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items(): for ac_key, ac_device in aircons.items():
for zone_key, zone in ac_device["zones"].items(): for zone_key, zone in ac_device["zones"].items():
# Only add zone vent controls when zone in vent control mode. # Only add zone vent controls when zone in vent control mode.
if zone["type"] == 0: if zone["type"] == 0:
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
if things := coordinator.data.get("myThings"): if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values(): for thing in things["things"].values():
if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2" if thing["channelDipState"] in [1, 2]: # 1 = "Blind", 2 = "Blind 2"
entities.append( entities.append(
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.BLIND) AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
) )
elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door" elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door"
entities.append( entities.append(
AdvantageAirThingCover(coordinator, thing, CoverDeviceClass.GARAGE) AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
) )
async_add_entities(entities) async_add_entities(entities)
@@ -58,11 +58,9 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, CoverEntity):
| CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_POSITION
) )
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone Vent.""" """Initialize an Advantage Air Zone Vent."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = self._zone["name"] self._attr_name = self._zone["name"]
@property @property
@@ -108,12 +106,12 @@ class AdvantageAirThingCover(AdvantageAirThingEntity, CoverEntity):
def __init__( def __init__(
self, self,
coordinator: AdvantageAirCoordinator, instance: AdvantageAirData,
thing: dict[str, Any], thing: dict[str, Any],
device_class: CoverDeviceClass, device_class: CoverDeviceClass,
) -> None: ) -> None:
"""Initialize an Advantage Air Things Cover.""" """Initialize an Advantage Air Things Cover."""
super().__init__(coordinator, thing) super().__init__(instance, thing)
self._attr_device_class = device_class self._attr_device_class = device_class
@property @property

View File

@@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry hass: HomeAssistant, config_entry: AdvantageAirDataConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
data = config_entry.runtime_data.data data = config_entry.runtime_data.coordinator.data
# Return only the relevant children # Return only the relevant children
return { return {

View File

@@ -9,17 +9,17 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AdvantageAirCoordinator from .models import AdvantageAirData
class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]): class AdvantageAirEntity(CoordinatorEntity):
"""Parent class for Advantage Air Entities.""" """Parent class for Advantage Air Entities."""
_attr_has_entity_name = True _attr_has_entity_name = True
def __init__(self, coordinator: AdvantageAirCoordinator) -> None: def __init__(self, instance: AdvantageAirData) -> None:
"""Initialize common aspects of an Advantage Air entity.""" """Initialize common aspects of an Advantage Air entity."""
super().__init__(coordinator) super().__init__(instance.coordinator)
self._attr_unique_id: str = self.coordinator.data["system"]["rid"] self._attr_unique_id: str = self.coordinator.data["system"]["rid"]
def update_handle_factory(self, func, *keys): def update_handle_factory(self, func, *keys):
@@ -41,9 +41,9 @@ class AdvantageAirEntity(CoordinatorEntity[AdvantageAirCoordinator]):
class AdvantageAirAcEntity(AdvantageAirEntity): class AdvantageAirAcEntity(AdvantageAirEntity):
"""Parent class for Advantage Air AC Entities.""" """Parent class for Advantage Air AC Entities."""
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize common aspects of an Advantage Air ac entity.""" """Initialize common aspects of an Advantage Air ac entity."""
super().__init__(coordinator) super().__init__(instance)
self.ac_key: str = ac_key self.ac_key: str = ac_key
self._attr_unique_id += f"-{ac_key}" self._attr_unique_id += f"-{ac_key}"
@@ -56,7 +56,7 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"], name=self.coordinator.data["aircons"][self.ac_key]["info"]["name"],
) )
self.async_update_ac = self.update_handle_factory( self.async_update_ac = self.update_handle_factory(
coordinator.api.aircon.async_update_ac, self.ac_key instance.api.aircon.async_update_ac, self.ac_key
) )
@property @property
@@ -73,16 +73,14 @@ class AdvantageAirAcEntity(AdvantageAirEntity):
class AdvantageAirZoneEntity(AdvantageAirAcEntity): class AdvantageAirZoneEntity(AdvantageAirAcEntity):
"""Parent class for Advantage Air Zone Entities.""" """Parent class for Advantage Air Zone Entities."""
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize common aspects of an Advantage Air zone entity.""" """Initialize common aspects of an Advantage Air zone entity."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self.zone_key: str = zone_key self.zone_key: str = zone_key
self._attr_unique_id += f"-{zone_key}" self._attr_unique_id += f"-{zone_key}"
self.async_update_zone = self.update_handle_factory( self.async_update_zone = self.update_handle_factory(
coordinator.api.aircon.async_update_zone, self.ac_key, self.zone_key instance.api.aircon.async_update_zone, self.ac_key, self.zone_key
) )
@property @property
@@ -95,11 +93,9 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
_attr_name = None _attr_name = None
def __init__( def __init__(self, instance: AdvantageAirData, thing: dict[str, Any]) -> None:
self, coordinator: AdvantageAirCoordinator, thing: dict[str, Any]
) -> None:
"""Initialize common aspects of an Advantage Air Things entity.""" """Initialize common aspects of an Advantage Air Things entity."""
super().__init__(coordinator) super().__init__(instance)
self._id = thing["id"] self._id = thing["id"]
self._attr_unique_id += f"-{self._id}" self._attr_unique_id += f"-{self._id}"
@@ -112,7 +108,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
name=thing["name"], name=thing["name"],
) )
self.async_update_value = self.update_handle_factory( self.async_update_value = self.update_handle_factory(
coordinator.api.things.async_update_value, self._id instance.api.things.async_update_value, self._id
) )
@property @property
@@ -121,7 +117,7 @@ class AdvantageAirThingEntity(AdvantageAirEntity):
return self.coordinator.data["myThings"]["things"][self._id] return self.coordinator.data["myThings"]["things"][self._id]
@property @property
def is_on(self) -> bool: def is_on(self):
"""Return if the thing is considered on.""" """Return if the thing is considered on."""
return self._data["value"] > 0 return self._data["value"] > 0

View File

@@ -9,8 +9,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirEntity, AdvantageAirThingEntity from .entity import AdvantageAirEntity, AdvantageAirThingEntity
from .models import AdvantageAirData
async def async_setup_entry( async def async_setup_entry(
@@ -20,21 +20,21 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir light platform.""" """Set up AdvantageAir light platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[LightEntity] = [] entities: list[LightEntity] = []
if my_lights := coordinator.data.get("myLights"): if my_lights := instance.coordinator.data.get("myLights"):
for light in my_lights["lights"].values(): for light in my_lights["lights"].values():
if light.get("relay"): if light.get("relay"):
entities.append(AdvantageAirLight(coordinator, light)) entities.append(AdvantageAirLight(instance, light))
else: else:
entities.append(AdvantageAirLightDimmable(coordinator, light)) entities.append(AdvantageAirLightDimmable(instance, light))
if things := coordinator.data.get("myThings"): if things := instance.coordinator.data.get("myThings"):
for thing in things["things"].values(): for thing in things["things"].values():
if thing["channelDipState"] == 4: # 4 = "Light (on/off)"" if thing["channelDipState"] == 4: # 4 = "Light (on/off)""
entities.append(AdvantageAirThingLight(coordinator, thing)) entities.append(AdvantageAirThingLight(instance, thing))
elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)"" elif thing["channelDipState"] == 5: # 5 = "Light (Dimmable)""
entities.append(AdvantageAirThingLightDimmable(coordinator, thing)) entities.append(AdvantageAirThingLightDimmable(instance, thing))
async_add_entities(entities) async_add_entities(entities)
@@ -45,11 +45,9 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.ONOFF} _attr_supported_color_modes = {ColorMode.ONOFF}
_attr_name = None _attr_name = None
def __init__( def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
) -> None:
"""Initialize an Advantage Air Light.""" """Initialize an Advantage Air Light."""
super().__init__(coordinator) super().__init__(instance)
self._id: str = light["id"] self._id: str = light["id"]
self._attr_unique_id += f"-{self._id}" self._attr_unique_id += f"-{self._id}"
@@ -61,7 +59,7 @@ class AdvantageAirLight(AdvantageAirEntity, LightEntity):
name=light["name"], name=light["name"],
) )
self.async_update_state = self.update_handle_factory( self.async_update_state = self.update_handle_factory(
coordinator.api.lights.async_update_state, self._id instance.api.lights.async_update_state, self._id
) )
@property @property
@@ -89,13 +87,11 @@ class AdvantageAirLightDimmable(AdvantageAirLight):
_attr_color_mode = ColorMode.BRIGHTNESS _attr_color_mode = ColorMode.BRIGHTNESS
_attr_supported_color_modes = {ColorMode.BRIGHTNESS} _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def __init__( def __init__(self, instance: AdvantageAirData, light: dict[str, Any]) -> None:
self, coordinator: AdvantageAirCoordinator, light: dict[str, Any]
) -> None:
"""Initialize an Advantage Air Dimmable Light.""" """Initialize an Advantage Air Dimmable Light."""
super().__init__(coordinator, light) super().__init__(instance, light)
self.async_update_value = self.update_handle_factory( self.async_update_value = self.update_handle_factory(
coordinator.api.lights.async_update_value, self._id instance.api.lights.async_update_value, self._id
) )
@property @property

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Bre77"], "codeowners": ["@Bre77"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/advantage_air", "documentation": "https://www.home-assistant.io/integrations/advantage_air",
"integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["advantage_air"], "loggers": ["advantage_air"],
"requirements": ["advantage-air==0.4.4"] "requirements": ["advantage-air==0.4.4"]

View File

@@ -0,0 +1,17 @@
"""The Advantage Air integration models."""
from __future__ import annotations
from dataclasses import dataclass
from advantage_air import advantage_air
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@dataclass
class AdvantageAirData:
"""Data for the Advantage Air integration."""
coordinator: DataUpdateCoordinator
api: advantage_air

View File

@@ -1,99 +0,0 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: |
Add mock_setup_entry common fixture.
Test unique_id of the entry in happy flow.
Split duplicate entry test from happy flow, use mock_config_entry.
Error flow should end in CREATE_ENTRY to test recovery.
Add data_description for ip_address (and port) to strings.json - tests fail with:
"Translation not found for advantage_air: config.step.user.data_description.ip_address"
config-flow:
status: todo
comment: Data descriptions missing
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: todo
docs-removal-instructions: todo
entity-event-setup:
status: exempt
comment: Entities do not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to be set.
docs-installation-parameters: done
entity-unavailable:
status: todo
comment: MyZone temp entity should be unavailable when MyZone is disabled rather than returning None.
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow:
status: exempt
comment: Integration connects to local device without authentication.
test-coverage:
status: todo
comment: |
Patch the library instead of mocking at integration level.
Split binary sensor tests into multiple tests (enable entities etc).
Split tests into Creation (right entities with right values), Actions (right library calls), and Other behaviors.
# Gold
devices:
status: todo
comment: Consider making every zone its own device for better naming and room assignment. Breaking change to split cover entities to separate devices.
diagnostics: done
discovery-update-info:
status: exempt
comment: Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices, not discoverable.
discovery:
status: exempt
comment: Check mDNS, DHCP, SSDP confirmed not feasible. Device is a generic Android device (android-xxxxxxxx) indistinguishable from other Android devices.
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: AC zones are static per unit and configured on the device itself.
entity-category: done
entity-device-class:
status: todo
comment: Consider using UPDATE device class for app update binary sensor instead of custom.
entity-disabled-by-default: done
entity-translations: todo
exception-translations:
status: todo
comment: HomeAssistantError in entity.py and ServiceValidationError in climate.py
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: Integration does not raise repair issues.
stale-devices:
status: exempt
comment: Zones are part of the AC unit, not separate removable devices.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -5,8 +5,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity from .entity import AdvantageAirAcEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_INACTIVE = "Inactive" ADVANTAGE_AIR_INACTIVE = "Inactive"
@@ -18,12 +18,10 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir select platform.""" """Set up AdvantageAir select platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
async_add_entities( async_add_entities(AdvantageAirMyZone(instance, ac_key) for ac_key in aircons)
AdvantageAirMyZone(coordinator, ac_key) for ac_key in aircons
)
class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity): class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
@@ -32,16 +30,16 @@ class AdvantageAirMyZone(AdvantageAirAcEntity, SelectEntity):
_attr_icon = "mdi:home-thermometer" _attr_icon = "mdi:home-thermometer"
_attr_name = "MyZone" _attr_name = "MyZone"
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air MyZone control.""" """Initialize an Advantage Air MyZone control."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_unique_id += "-myzone" self._attr_unique_id += "-myzone"
self._attr_options = [ADVANTAGE_AIR_INACTIVE] self._attr_options = [ADVANTAGE_AIR_INACTIVE]
self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE} self._number_to_name = {0: ADVANTAGE_AIR_INACTIVE}
self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0} self._name_to_number = {ADVANTAGE_AIR_INACTIVE: 0}
if "aircons" in coordinator.data: if "aircons" in instance.coordinator.data:
for zone in coordinator.data["aircons"][ac_key]["zones"].values(): for zone in instance.coordinator.data["aircons"][ac_key]["zones"].values():
if zone["type"] > 0: if zone["type"] > 0:
self._name_to_number[zone["name"]] = zone["number"] self._name_to_number[zone["name"]] = zone["number"]
self._number_to_name[zone["number"]] = zone["name"] self._number_to_name[zone["number"]] = zone["name"]

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
SensorDeviceClass, SensorDeviceClass,
SensorEntity, SensorEntity,
@@ -12,15 +14,17 @@ from homeassistant.components.sensor import (
) )
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_OPEN from .const import ADVANTAGE_AIR_STATE_OPEN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
from .models import AdvantageAirData
ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes" ADVANTAGE_AIR_SET_COUNTDOWN_VALUE = "minutes"
ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min" ADVANTAGE_AIR_SET_COUNTDOWN_UNIT = "min"
ADVANTAGE_AIR_SERVICE_SET_TIME_TO = "set_time_to"
PARALLEL_UPDATES = 0 PARALLEL_UPDATES = 0
@@ -32,25 +36,30 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir sensor platform.""" """Set up AdvantageAir sensor platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[SensorEntity] = [] entities: list[SensorEntity] = []
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items(): for ac_key, ac_device in aircons.items():
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "On")) entities.append(AdvantageAirTimeTo(instance, ac_key, "On"))
entities.append(AdvantageAirTimeTo(coordinator, ac_key, "Off")) entities.append(AdvantageAirTimeTo(instance, ac_key, "Off"))
for zone_key, zone in ac_device["zones"].items(): for zone_key, zone in ac_device["zones"].items():
# Only show damper and temp sensors when zone is in temperature control # Only show damper and temp sensors when zone is in temperature control
if zone["type"] != 0: if zone["type"] != 0:
entities.append(AdvantageAirZoneVent(coordinator, ac_key, zone_key)) entities.append(AdvantageAirZoneVent(instance, ac_key, zone_key))
entities.append(AdvantageAirZoneTemp(coordinator, ac_key, zone_key)) entities.append(AdvantageAirZoneTemp(instance, ac_key, zone_key))
# Only show wireless signal strength sensors when using wireless sensors # Only show wireless signal strength sensors when using wireless sensors
if zone["rssi"] > 0: if zone["rssi"] > 0:
entities.append( entities.append(AdvantageAirZoneSignal(instance, ac_key, zone_key))
AdvantageAirZoneSignal(coordinator, ac_key, zone_key)
)
async_add_entities(entities) async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
ADVANTAGE_AIR_SERVICE_SET_TIME_TO,
{vol.Required("minutes"): cv.positive_int},
"set_time_to",
)
class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity): class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
"""Representation of Advantage Air timer control.""" """Representation of Advantage Air timer control."""
@@ -58,11 +67,9 @@ class AdvantageAirTimeTo(AdvantageAirAcEntity, SensorEntity):
_attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT _attr_native_unit_of_measurement = ADVANTAGE_AIR_SET_COUNTDOWN_UNIT
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, action: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, action: str
) -> None:
"""Initialize the Advantage Air timer control.""" """Initialize the Advantage Air timer control."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self.action = action self.action = action
self._time_key = f"countDownTo{action}" self._time_key = f"countDownTo{action}"
self._attr_name = f"Time to {action}" self._attr_name = f"Time to {action}"
@@ -93,11 +100,9 @@ class AdvantageAirZoneVent(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone Vent Sensor.""" """Initialize an Advantage Air Zone Vent Sensor."""
super().__init__(coordinator, ac_key, zone_key=zone_key) super().__init__(instance, ac_key, zone_key=zone_key)
self._attr_name = f"{self._zone['name']} vent" self._attr_name = f"{self._zone['name']} vent"
self._attr_unique_id += "-vent" self._attr_unique_id += "-vent"
@@ -123,11 +128,9 @@ class AdvantageAirZoneSignal(AdvantageAirZoneEntity, SensorEntity):
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone wireless signal sensor.""" """Initialize an Advantage Air Zone wireless signal sensor."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} signal" self._attr_name = f"{self._zone['name']} signal"
self._attr_unique_id += "-signal" self._attr_unique_id += "-signal"
@@ -159,11 +162,9 @@ class AdvantageAirZoneTemp(AdvantageAirZoneEntity, SensorEntity):
_attr_entity_registry_enabled_default = False _attr_entity_registry_enabled_default = False
_attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__( def __init__(self, instance: AdvantageAirData, ac_key: str, zone_key: str) -> None:
self, coordinator: AdvantageAirCoordinator, ac_key: str, zone_key: str
) -> None:
"""Initialize an Advantage Air Zone Temp Sensor.""" """Initialize an Advantage Air Zone Temp Sensor."""
super().__init__(coordinator, ac_key, zone_key) super().__init__(instance, ac_key, zone_key)
self._attr_name = f"{self._zone['name']} temperature" self._attr_name = f"{self._zone['name']} temperature"
self._attr_unique_id += "-temp" self._attr_unique_id += "-temp"

View File

@@ -1,25 +0,0 @@
"""Services for Advantage Air integration."""
from __future__ import annotations
import voluptuous as vol
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, service
from .const import DOMAIN
@callback
def async_setup_services(hass: HomeAssistant) -> None:
"""Home Assistant services."""
service.async_register_platform_entity_service(
hass,
DOMAIN,
"set_time_to",
entity_domain=SENSOR_DOMAIN,
schema={vol.Required("minutes"): cv.positive_int},
func="set_time_to",
)

View File

@@ -17,11 +17,6 @@
} }
} }
}, },
"exceptions": {
"update_failed": {
"message": "An error occurred while updating from the Advantage Air API: {error}"
}
},
"services": { "services": {
"set_time_to": { "set_time_to": {
"description": "Controls timers to turn the system on or off after a set number of minutes.", "description": "Controls timers to turn the system on or off after a set number of minutes.",

View File

@@ -13,8 +13,8 @@ from .const import (
ADVANTAGE_AIR_STATE_OFF, ADVANTAGE_AIR_STATE_OFF,
ADVANTAGE_AIR_STATE_ON, ADVANTAGE_AIR_STATE_ON,
) )
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity from .entity import AdvantageAirAcEntity, AdvantageAirThingEntity
from .models import AdvantageAirData
async def async_setup_entry( async def async_setup_entry(
@@ -24,20 +24,20 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir switch platform.""" """Set up AdvantageAir switch platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
entities: list[SwitchEntity] = [] entities: list[SwitchEntity] = []
if aircons := coordinator.data.get("aircons"): if aircons := instance.coordinator.data.get("aircons"):
for ac_key, ac_device in aircons.items(): for ac_key, ac_device in aircons.items():
if ac_device["info"]["freshAirStatus"] != "none": if ac_device["info"]["freshAirStatus"] != "none":
entities.append(AdvantageAirFreshAir(coordinator, ac_key)) entities.append(AdvantageAirFreshAir(instance, ac_key))
if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]: if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]:
entities.append(AdvantageAirMyFan(coordinator, ac_key)) entities.append(AdvantageAirMyFan(instance, ac_key))
if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]: if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]:
entities.append(AdvantageAirNightMode(coordinator, ac_key)) entities.append(AdvantageAirNightMode(instance, ac_key))
if things := coordinator.data.get("myThings"): if things := instance.coordinator.data.get("myThings"):
entities.extend( entities.extend(
AdvantageAirRelay(coordinator, thing) AdvantageAirRelay(instance, thing)
for thing in things["things"].values() for thing in things["things"].values()
if thing["channelDipState"] == 8 # 8 = Other relay if thing["channelDipState"] == 8 # 8 = Other relay
) )
@@ -51,9 +51,9 @@ class AdvantageAirFreshAir(AdvantageAirAcEntity, SwitchEntity):
_attr_name = "Fresh air" _attr_name = "Fresh air"
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air fresh air control.""" """Initialize an Advantage Air fresh air control."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_unique_id += "-freshair" self._attr_unique_id += "-freshair"
@property @property
@@ -77,9 +77,9 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity):
_attr_name = "MyFan" _attr_name = "MyFan"
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air MyFan control.""" """Initialize an Advantage Air MyFan control."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_unique_id += "-myfan" self._attr_unique_id += "-myfan"
@property @property
@@ -103,9 +103,9 @@ class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity):
_attr_name = "MySleep$aver" _attr_name = "MySleep$aver"
_attr_device_class = SwitchDeviceClass.SWITCH _attr_device_class = SwitchDeviceClass.SWITCH
def __init__(self, coordinator: AdvantageAirCoordinator, ac_key: str) -> None: def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
"""Initialize an Advantage Air Night Mode control.""" """Initialize an Advantage Air Night Mode control."""
super().__init__(coordinator, ac_key) super().__init__(instance, ac_key)
self._attr_unique_id += "-nightmode" self._attr_unique_id += "-nightmode"
@property @property

View File

@@ -7,8 +7,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry from . import AdvantageAirDataConfigEntry
from .const import DOMAIN from .const import DOMAIN
from .coordinator import AdvantageAirCoordinator
from .entity import AdvantageAirEntity from .entity import AdvantageAirEntity
from .models import AdvantageAirData
async def async_setup_entry( async def async_setup_entry(
@@ -18,9 +18,9 @@ async def async_setup_entry(
) -> None: ) -> None:
"""Set up AdvantageAir update platform.""" """Set up AdvantageAir update platform."""
coordinator = config_entry.runtime_data instance = config_entry.runtime_data
async_add_entities([AdvantageAirApp(coordinator)]) async_add_entities([AdvantageAirApp(instance)])
class AdvantageAirApp(AdvantageAirEntity, UpdateEntity): class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
@@ -28,9 +28,9 @@ class AdvantageAirApp(AdvantageAirEntity, UpdateEntity):
_attr_name = "App" _attr_name = "App"
def __init__(self, coordinator: AdvantageAirCoordinator) -> None: def __init__(self, instance: AdvantageAirData) -> None:
"""Initialize the Advantage Air App.""" """Initialize the Advantage Air App."""
super().__init__(coordinator) super().__init__(instance)
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])}, identifiers={(DOMAIN, self.coordinator.data["system"]["rid"])},
manufacturer="Advantage Air", manufacturer="Advantage Air",

View File

@@ -4,7 +4,6 @@
"codeowners": ["@Noltari"], "codeowners": ["@Noltari"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aemet", "documentation": "https://www.home-assistant.io/integrations/aemet",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aemet_opendata"], "loggers": ["aemet_opendata"],
"requirements": ["AEMET-OpenData==0.6.4"] "requirements": ["AEMET-OpenData==0.6.4"]

View File

@@ -74,7 +74,7 @@ class AemetWeather(
self._attr_unique_id = unique_id self._attr_unique_id = unique_id
@property @property
def condition(self) -> str | None: def condition(self):
"""Return the current condition.""" """Return the current condition."""
cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION]) cond = self.get_aemet_value([AOD_WEATHER, AOD_CONDITION])
return CONDITIONS_MAP.get(cond) return CONDITIONS_MAP.get(cond)
@@ -90,31 +90,31 @@ class AemetWeather(
return self.get_aemet_forecast(AOD_FORECAST_HOURLY) return self.get_aemet_forecast(AOD_FORECAST_HOURLY)
@property @property
def humidity(self) -> float | None: def humidity(self):
"""Return the humidity.""" """Return the humidity."""
return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY]) return self.get_aemet_value([AOD_WEATHER, AOD_HUMIDITY])
@property @property
def native_pressure(self) -> float | None: def native_pressure(self):
"""Return the pressure.""" """Return the pressure."""
return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE]) return self.get_aemet_value([AOD_WEATHER, AOD_PRESSURE])
@property @property
def native_temperature(self) -> float | None: def native_temperature(self):
"""Return the temperature.""" """Return the temperature."""
return self.get_aemet_value([AOD_WEATHER, AOD_TEMP]) return self.get_aemet_value([AOD_WEATHER, AOD_TEMP])
@property @property
def wind_bearing(self) -> float | None: def wind_bearing(self):
"""Return the wind bearing.""" """Return the wind bearing."""
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION]) return self.get_aemet_value([AOD_WEATHER, AOD_WIND_DIRECTION])
@property @property
def native_wind_gust_speed(self) -> float | None: def native_wind_gust_speed(self):
"""Return the wind gust speed in native units.""" """Return the wind gust speed in native units."""
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX]) return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED_MAX])
@property @property
def native_wind_speed(self) -> float | None: def native_wind_speed(self):
"""Return the wind speed.""" """Return the wind speed."""
return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED]) return self.get_aemet_value([AOD_WEATHER, AOD_WIND_SPEED])

View File

@@ -4,7 +4,6 @@
"codeowners": [], "codeowners": [],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aftership", "documentation": "https://www.home-assistant.io/integrations/aftership",
"integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["pyaftership==21.11.0"] "requirements": ["pyaftership==21.11.0"]
} }

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