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 for diagnostic data collection
-
Repairs: 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
-
Check manifest.json : Look for "quality_scale" key to determine integration level
-
Bronze Rules: Always required for any integration with quality scale
-
Higher Tier Rules: Only apply if integration targets that tier or higher
-
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
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 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 class MyEntity(CoordinatorEntity[MyCoordinator]): _attr_has_entity_name = True
Runtime Data Storage
- Use ConfigEntry.runtime_data: Store non-persistent runtime data 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: 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 : { "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 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 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: try: await client.get_data() except MyException: errors["base"] = "cannot_connect"
-
Duplicate Prevention: Prevent duplicate configurations:
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: 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.) { "zeroconf": ["_mydevice._tcp.local."] }
-
Discovery Handler: Implement appropriate async_step_* method: 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 aiozc = await zeroconf.async_get_async_instance(hass)
-
SSDP Discovery: Register callbacks with cleanup 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 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 : 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: 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:
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 platform.async_register_entity_service( "my_entity_service", {vol.Required("parameter"): cv.string}, "handle_service_method" )
-
Service Schema: Always validate input 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: 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: 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: 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: 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: 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 : 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: @property def available(self) -> bool: """Return if entity is available.""" return super().available and self.identifier in self.coordinator.data
-
Direct Update Pattern: 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: _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: 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: 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
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 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 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: class MySensor(SensorEntity): _attr_has_entity_name = True _attr_translation_key = "phase_voltage"
-
Create strings.json with translations: { "entity": { "sensor": { "phase_voltage": { "name": "Phase voltage" } } } }
Exception Translations (Gold)
-
Translatable Errors: Use translation keys for user-facing exceptions
-
Implementation: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="end_date_before_start_date", )
-
Add to strings.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: { "entity": { "sensor": { "tree_pollen": { "default": "mdi:tree", "state": { "high": "mdi:tree-outline" } } } } }
-
Range-based Icons (for numeric values): { "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):
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
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
@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
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
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
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