Python Home Assistant Automation — Deep Dive
Home Assistant Architecture
Home Assistant Core is a Python application built on asyncio. Its architecture consists of several key components:
Event Bus — Every state change, service call, and time event generates an event on the central event bus. Automations and integrations subscribe to events they care about.
State Machine — Tracks the current state and attributes of every entity. State changes fire state_changed events.
Service Registry — Maps service calls (like light.turn_on) to the integration code that handles them.
Config Entries — Manages integration configuration, credentials, and lifecycle.
The entire system runs in a single Python process using asyncio for concurrency. Long-running or blocking operations use the thread pool executor.
Building Custom Integrations
Custom integrations live in config/custom_components/<domain>/:
custom_components/
└── my_sensor/
├── __init__.py
├── manifest.json
├── config_flow.py
├── sensor.py
└── const.py
manifest.json
{
"domain": "my_sensor",
"name": "My Custom Sensor",
"version": "1.0.0",
"documentation": "https://github.com/user/my_sensor",
"dependencies": [],
"codeowners": ["@username"],
"requirements": ["some-pypi-package==1.2.3"],
"iot_class": "local_polling"
}
Sensor Platform
# sensor.py
from homeassistant.components.sensor import (
SensorEntity,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.entity_platform import AddEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
coordinator = hass.data[DOMAIN][config_entry.entry_id]
async_add_entities([
MyTemperatureSensor(coordinator),
MyHumiditySensor(coordinator),
])
class MyTemperatureSensor(SensorEntity):
_attr_device_class = SensorDeviceClass.TEMPERATURE
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
_attr_name = "My Temperature"
_attr_unique_id = "my_sensor_temperature"
def __init__(self, coordinator):
self.coordinator = coordinator
@property
def native_value(self):
return self.coordinator.data.get("temperature")
@property
def available(self):
return self.coordinator.last_update_success
Data Update Coordinator
The coordinator pattern centralizes API calls and distributes data to entities:
# __init__.py
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from datetime import timedelta
import logging
_LOGGER = logging.getLogger(__name__)
class MySensorCoordinator(DataUpdateCoordinator):
def __init__(self, hass, api_client):
super().__init__(
hass,
_LOGGER,
name="My Sensor",
update_interval=timedelta(seconds=30),
)
self.api = api_client
async def _async_update_data(self):
try:
return await self.api.get_readings()
except ConnectionError as err:
raise UpdateFailed(f"Error communicating with sensor: {err}")
async def async_setup_entry(hass, entry):
api = MySensorAPI(entry.data["host"])
coordinator = MySensorCoordinator(hass, api)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, ["sensor"])
return True
Config Flow for UI Setup
# config_flow.py
from homeassistant import config_entries
import voluptuous as vol
class MyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
VERSION = 1
async def async_step_user(self, user_input=None):
errors = {}
if user_input is not None:
# Validate the connection
try:
api = MySensorAPI(user_input["host"])
await api.test_connection()
except ConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
title=f"Sensor at {user_input['host']}",
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({
vol.Required("host"): str,
vol.Optional("port", default=8080): int,
}),
errors=errors,
)
Advanced Automation Templates
Home Assistant’s template engine (Jinja2) enables dynamic automations:
Adaptive Lighting Based on Sun Position
- alias: "Adaptive Living Room Brightness"
trigger:
- platform: time_pattern
minutes: "/5"
condition:
- condition: state
entity_id: light.living_room
state: "on"
action:
- service: light.turn_on
target:
entity_id: light.living_room
data:
brightness_pct: >
{% set elevation = state_attr('sun.sun', 'elevation') %}
{% if elevation > 30 %}
100
{% elif elevation > 10 %}
{{ (elevation * 3) | int }}
{% elif elevation > 0 %}
30
{% else %}
{{ [80 - (elevation | abs * 2), 20] | max | int }}
{% endif %}
color_temp_kelvin: >
{% set elevation = state_attr('sun.sun', 'elevation') %}
{% if elevation > 20 %}
5000
{% elif elevation > 0 %}
{{ 3000 + (elevation * 100) | int }}
{% else %}
2700
{% endif %}
Template Sensors
Create computed sensors from raw data:
# configuration.yaml
template:
- sensor:
- name: "House Energy Cost Today"
unit_of_measurement: "€"
state: >
{% set kwh = states('sensor.energy_daily') | float(0) %}
{% set hour = now().hour %}
{% if 7 <= hour < 23 %}
{{ (kwh * 0.28) | round(2) }}
{% else %}
{{ (kwh * 0.15) | round(2) }}
{% endif %}
- name: "Rooms Occupied"
state: >
{% set rooms = [
'binary_sensor.motion_living_room',
'binary_sensor.motion_kitchen',
'binary_sensor.motion_bedroom',
'binary_sensor.motion_office'
] %}
{{ rooms | select('is_state', 'on') | list | count }}
AppDaemon: Python-First Automations
AppDaemon is an execution engine that runs Python apps alongside Home Assistant. It provides a more powerful programming model than YAML automations:
# apps/climate_control.py
import appdaemon.plugins.hass.hassapi as hass
from datetime import datetime, time
class ClimateController(hass.Hass):
def initialize(self):
self.comfort_temp = self.args.get("comfort_temp", 22)
self.away_temp = self.args.get("away_temp", 18)
self.sleep_temp = self.args.get("sleep_temp", 19)
self.thermostat = self.args["thermostat"]
# React to presence changes
self.listen_state(self.presence_changed, "group.family")
# Schedule temperature adjustments
self.run_daily(self.morning_routine, time(6, 30))
self.run_daily(self.night_routine, time(22, 30))
# React to window opening
for window in self.args.get("windows", []):
self.listen_state(self.window_changed, window)
def presence_changed(self, entity, attribute, old, new, kwargs):
if new == "not_home":
self.set_temperature(self.away_temp)
self.log(f"Everyone left, setting {self.away_temp}°C")
elif old == "not_home" and new == "home":
self.set_temperature(self.comfort_temp)
self.log(f"Welcome home, setting {self.comfort_temp}°C")
def window_changed(self, entity, attribute, old, new, kwargs):
if new == "on": # window opened
self.call_service("climate/turn_off",
entity_id=self.thermostat)
self.log(f"Window opened ({entity}), HVAC off")
elif new == "off": # window closed
self.call_service("climate/turn_on",
entity_id=self.thermostat)
self.set_temperature(self.comfort_temp)
def morning_routine(self, kwargs):
if self.get_state("group.family") == "home":
self.set_temperature(self.comfort_temp)
def night_routine(self, kwargs):
self.set_temperature(self.sleep_temp)
def set_temperature(self, temp):
self.call_service("climate/set_temperature",
entity_id=self.thermostat,
temperature=temp)
# apps/apps.yaml
climate_controller:
module: climate_control
class: ClimateController
thermostat: climate.living_room
comfort_temp: 22
away_temp: 17
sleep_temp: 19
windows:
- binary_sensor.window_living_room
- binary_sensor.window_bedroom
REST API Integration
Home Assistant exposes a REST API for external control:
import requests
HA_URL = "http://homeassistant.local:8123"
TOKEN = "your_long_lived_access_token"
headers = {
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
}
# Get entity state
response = requests.get(
f"{HA_URL}/api/states/sensor.outdoor_temperature",
headers=headers
)
state = response.json()
print(f"Temperature: {state['state']}°C")
# Call a service
requests.post(
f"{HA_URL}/api/services/light/turn_on",
headers=headers,
json={
"entity_id": "light.living_room",
"brightness_pct": 75
}
)
# Fire an event
requests.post(
f"{HA_URL}/api/events/custom_alert",
headers=headers,
json={"source": "external_script", "severity": "warning"}
)
WebSocket API for Real-Time Updates
import asyncio
import aiohttp
import json
async def listen_state_changes():
async with aiohttp.ClientSession() as session:
async with session.ws_connect(
f"ws://homeassistant.local:8123/api/websocket"
) as ws:
# Authentication
auth_msg = await ws.receive_json()
await ws.send_json({"type": "auth", "access_token": TOKEN})
auth_result = await ws.receive_json()
# Subscribe to state changes
await ws.send_json({
"id": 1,
"type": "subscribe_events",
"event_type": "state_changed"
})
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
data = json.loads(msg.data)
if data.get("type") == "event":
event = data["event"]["data"]
entity = event["entity_id"]
new_state = event["new_state"]["state"]
print(f"{entity}: {new_state}")
asyncio.run(listen_state_changes())
Production Deployment Patterns
Backup and Version Control
# automations, scripts, and scenes in version control
# .gitignore
*.db
*.log
secrets.yaml
.storage/
tts/
Keep secrets.yaml separate and use it for all credentials:
# secrets.yaml (NOT in version control)
mqtt_password: "secret123"
latitude: 52.5200
longitude: 13.4050
# configuration.yaml
mqtt:
password: !secret mqtt_password
Monitoring Home Assistant
# Monitor HA itself
sensor:
- platform: systemmonitor
resources:
- type: processor_use
- type: memory_use_percent
- type: disk_use_percent
arg: /
automation:
- alias: "Alert on High CPU"
trigger:
- platform: numeric_state
entity_id: sensor.processor_use
above: 90
for: "00:05:00"
action:
- service: notify.mobile_app
data:
message: "Home Assistant CPU at {{ states('sensor.processor_use') }}%"
ESPHome for Custom Sensors
ESPHome compiles YAML into firmware for ESP32/ESP8266 boards that integrate natively with Home Assistant:
# esphome/garage_sensor.yaml
esphome:
name: garage-sensor
esp32:
board: esp32dev
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
api:
encryption:
key: !secret api_key
sensor:
- platform: dht
pin: GPIO4
temperature:
name: "Garage Temperature"
humidity:
name: "Garage Humidity"
update_interval: 60s
binary_sensor:
- platform: gpio
pin: GPIO5
name: "Garage Door"
device_class: garage_door
This creates a sensor device that Home Assistant automatically discovers and integrates, with no custom integration code needed.
One thing to remember: Home Assistant’s true power is the combination of its universal Python integration framework, local-first architecture, and thriving ecosystem — whether you use YAML automations, AppDaemon Python apps, or build custom integrations, you have full control over your smart home without surrendering your data to the cloud.
See Also
- Python Behavior Trees Robotics How robots make decisions using a tree-shaped rulebook that keeps them organized, like a flowchart that tells a robot what to do in every situation.
- Python Bluetooth Ble How Python connects to fitness trackers, smart locks, and wireless sensors using the invisible radio signals all around you.
- Python Circuitpython Hardware Why CircuitPython makes wiring up LEDs, sensors, and motors as easy as plugging in a USB drive.
- Python Computer Vision Autonomous How self-driving cars use cameras and Python to see the road, spot pedestrians, read signs, and understand traffic — like giving a car human eyes and a brain.
- Python Lidar Point Cloud Processing How self-driving cars use millions of laser dots to build a 3D picture of the world around them, and how Python helps make sense of it all.