Apps Overview
An app is a Python class that reacts to Home Assistant events and controls devices. Each app has its own config, state, and a set of typed accessors — self.bus, self.scheduler, self.api, self.states, self.cache, and self.task_bucket — for interacting with HA.
Defining an App
Every app is a Python class that inherits from App. App manages handlers, scheduling, and the connection to Home Assistant. The on_initialize lifecycle hook runs at startup, before any events arrive.
from hassette import App, AppConfig, D, states
class ExampleApp(App[AppConfig]):
async def on_initialize(self):
self.logger.info("Hello from ExampleApp!")
# Subscribe using the bus helper
await self.bus.on_state_change(
"light.living_room",
handler=self.on_light_change,
name="living_room_light",
)
async def on_light_change(
self,
new_state: D.StateNew[states.LightState],
):
self.logger.info("Light changed to %s", new_state.value)
What's D.StateNew[states.LightState]?
That annotation is dependency injection. The handler declares what data it needs, and Hassette extracts and types it from the event automatically. The Writing Handlers page covers how it works. For now, just notice the pattern.
Two more things to notice in the example. Every method is async def, and the registration call is awaited — that pattern holds for all bus, scheduler, and API calls, and a missing await silently does nothing (see Call Services below) — Async Basics explains why. The name= parameter is required on every subscription; it labels the listener in logs and the web UI.
Configuration
AppConfig loads and validates an app's settings from hassette.toml and environment variables. A subclass declares typed fields; Hassette populates them at startup.
from hassette import App, AppConfig
class MyAppConfig(AppConfig):
# Define fields with types and optional defaults
location_name: str
threshold: float = 25.0
notify_target: str = "mobile_app_phone"
# Pass the config class to the App generic
class MyApp(App[MyAppConfig]):
async def on_initialize(self):
# self.app_config is typed as MyAppConfig
self.logger.info("Starting %s monitoring", self.app_config.location_name)
self.app_config on the app instance is typed as the declared subclass, so the IDE and Pyright know the exact shape.
Environment Variables
SettingsConfigDict(env_prefix="...") scopes environment variable injection to a prefix, preventing collisions between multiple apps running in the same process.
from pydantic_settings import SettingsConfigDict
from hassette import AppConfig
class MyAppConfig(AppConfig):
model_config = SettingsConfigDict(env_prefix="MYAPP_")
api_key: str
With env_prefix="MYAPP_", the field api_key reads from MYAPP_API_KEY. Fields without a matching environment variable fall back to their declared defaults. Required fields (no default) raise a validation error at startup if absent.
Base Fields
Every AppConfig includes three built-in fields:
instance_name: a string that uniquely identifies one running instance of the app. Defaults to an empty string; Hassette derives a display name from the class name when it is not set.log_level: controls the logging verbosity for this app's logger. Inherits the process-level default when not set.app_key: the app's key fromhassette.toml, set by the framework. Don't set it directly; framework-reserved values are rejected with a validation error. Read it at runtime viaself.app_key— useful for logging or cross-app messaging.
AppConfig allows arbitrary extra fields by default. A subclass can tighten this by setting extra="forbid" in its own model_config.
TOML Registration
The hassette.toml file registers each app and supplies its config values. See App Configuration for the full reference.
[hassette.apps.my_monitor]
filename = "my_monitor.py"
class_name = "MyApp"
enabled = true
# Configuration matches your AppConfig model
[[hassette.apps.my_monitor.config]]
location_name = "Kitchen"
threshold = 30.0
Dates and Times
self.now() returns the current time as a ZonedDateTime from the whenever library, which ships with Hassette — no separate install needed. All scheduler parameters, persistent storage examples, and custom state definitions use whenever types.
from whenever import TimeDelta, ZonedDateTime
last_seen: ZonedDateTime = self.now()
next_run = self.now().add(hours=2) # 2 hours from now
elapsed: TimeDelta = self.now() - last_seen
whenever is always timezone-aware and immutable. Mixing naive and aware times is a type error that Pyright catches before the code runs. Python's stdlib datetime permits that class of mistake; whenever does not.
What an App Can Do
React to Events
self.bus subscribes to Home Assistant state changes, attribute changes, and service calls. The bus delivers each matching event to every registered handler.
self.on_change_listener = await self.bus.on_state_change("light.kitchen", handler=self.on_change, name="kitchen_light")
See the Bus page for filtering, predicates, debounce, and throttle options.
Schedule Jobs
self.scheduler runs functions on a schedule.
await self.scheduler.run_hourly(self.log_status)
See the Scheduler page for triggers, job groups, and jitter.
Read Entity States
self.states provides instant access to the current state of any entity, without an API call.
current_state = self.states.light["kitchen"].value
self.logger.info("Current state: %s", current_state)
See the States page for typed domain access and custom state models.
Call Services
self.api calls Home Assistant REST and WebSocket services.
await self.api.call_service("light", "turn_on", entity_id="light.kitchen")
Forgetting await on API calls
Every self.api.* method is a coroutine. It must be awaited. Writing self.api.call_service(...) without await returns a coroutine object and silently does nothing: no error is raised, no service is called, and no log message appears. If an API call seems to have no effect, check that await is present.
See the API page for state access, entity management, and more.
Persist Data
self.cache stores values that survive app restarts. Reads and writes go through a disk-backed store scoped to the app instance.
# Load counter from cache, defaulting to 0
self.counter = self.cache.get("counter", 0)
# Increment and save back
self.counter += 1
self.cache["counter"] = self.counter
See the Cache page for typed access, TTL, and cache invalidation.
Run Background Work
self.task_bucket spawns fire-and-forget coroutines and offloads blocking calls to a thread pool. All tracked tasks cancel automatically on shutdown.
# Fire off a background coroutine — the bucket tracks and cancels it on shutdown
self.task_bucket.spawn(self.poll_sensor(), name="poll_sensor")
See the Task Bucket page for run_in_thread, make_async_adapter, and cross-thread communication.
Restricting to a Single App
The @only_app decorator prevents all other apps from loading while the decorated class is present. It is intended for development isolation: one app runs while the rest are silenced, without editing hassette.toml.
from hassette import App, AppConfig, only_app
from pydantic_settings import SettingsConfigDict
class MyConfig(AppConfig):
model_config = SettingsConfigDict(env_prefix="my_")
@only_app
class MyApp(App[MyConfig]):
...
Only one class in the project may carry @only_app at a time. Hassette raises an error at startup if more than one is found.
In production mode, the decorator is ignored by default. allow_only_app_in_prod = true in hassette.toml overrides this behavior.
Broadcasting Between Apps
self.bus.emit() broadcasts an in-process event to all apps subscribed to a given topic. The event never reaches Home Assistant and is not persisted across restarts.
self.bus.on(topic=...) subscribes to a named topic. D.EventData[T] follows the same dependency injection pattern as D.StateNew — replace T with the payload class, so D.EventData[LightsSyncedData] delivers the payload as a LightsSyncedData object.
@dataclass(frozen=True, slots=True)
class LightsSyncedData:
source: str
class LightManagerApp(App[AppConfig]):
async def on_initialize(self) -> None:
await self.bus.on_state_change(
"light.kitchen",
handler=self.on_kitchen_change,
name="kitchen_sync",
)
async def on_kitchen_change(self, state: D.StateNew[states.LightState]) -> None:
await self.bus.emit("lights_synced", LightsSyncedData(source=self.instance_name))
class LoggerApp(App[AppConfig]):
async def on_initialize(self) -> None:
await self.bus.on(topic="lights_synced", handler=self.on_lights_synced, name="lights_synced_log")
async def on_lights_synced(self, data: D.EventData[LightsSyncedData]) -> None:
if data.source == self.instance_name:
return
self.logger.info("Lights synced by %s", data.source)
Self-delivery
An app that both emits and subscribes on the same topic receives its own events. To filter self-emitted events, include a source field on the emitted dataclass (as LightsSyncedData does above) and guard in the handler: if data.source == self.instance_name: return.
Synchronous Apps
AppSync runs automations written without async/await. Hassette executes the app's lifecycle hooks in a thread pool so blocking code does not stall the event loop. The bus, scheduler, and API expose synchronous facades via .sync (self.bus.sync, self.scheduler.sync, self.api.sync), so registrations and calls work without await.
AppSync fits apps built on blocking libraries and migrations from synchronous frameworks. Prefer async App for new code. See Lifecycle for the sync hook details and a full example.
Next Steps
- Lifecycle:
on_initialize,on_shutdown, and automatic resource cleanup. - Task Bucket: background tasks, thread offloading, and cross-thread communication.