1167 lines
40 KiB
Markdown
1167 lines
40 KiB
Markdown
# GitHub Copilot & Claude Code Instructions
|
|
|
|
This repository contains the core of Home Assistant, a Python 3 based home automation application.
|
|
|
|
## 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
|
|
|
|
### 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 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)
|
|
|
|
## 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
|
|
- **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
|
|
|
|
## 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"]
|
|
}
|
|
```
|
|
|
|
### 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
|
|
|
|
### 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)
|
|
|
|
### 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
|
|
|
|
### 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)
|
|
```
|
|
|
|
### 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
|
|
```
|
|
|
|
## 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
|
|
|
|
## Diagnostics and Repairs
|
|
|
|
### Integration Diagnostics
|
|
- **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
|
|
|
|
### Repair Issues
|
|
- **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
|
|
|
|
### 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
|
|
|
|
## Development Commands
|
|
|
|
### Code Quality & Linting
|
|
- **Run all linters on all files**: `pre-commit run --all-files`
|
|
- **Run linters on staged files only**: `pre-commit 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
|
|
- **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
|
|
```
|
|
- **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
|
|
```
|
|
|
|
### 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
|
|
```
|
|
|
|
### 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
|
|
|
|
### 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
|
|
|
|
## 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
|
|
)
|
|
```
|
|
|
|
### Entity Performance Optimization
|
|
```python
|
|
# Use __slots__ for memory efficiency
|
|
class MySensor(SensorEntity):
|
|
__slots__ = ("_attr_native_value", "_attr_available")
|
|
|
|
@property
|
|
def should_poll(self) -> bool:
|
|
"""Disable polling when using coordinator."""
|
|
return False # ✅ Let coordinator handle updates
|
|
```
|
|
|
|
## Testing Patterns
|
|
|
|
### 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.mark.parametrize("init_integration", [Platform.SENSOR], indirect=True)
|
|
@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
|
|
async def init_integration(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_device_api: MagicMock,
|
|
) -> MockConfigEntry:
|
|
"""Set up the integration for testing."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
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
|
|
``` |