Mental Model
This page covers how AppDaemon and Hassette differ at the design level — not just syntax, but philosophy. Understanding these differences helps you write idiomatic Hassette code instead of translating AppDaemon patterns one-for-one.
Execution Model
AppDaemon runs each app in a separate thread. This means you can write synchronous code without worrying about blocking the event loop — long-running operations work fine because they run in their own thread.
Hassette runs all apps in a single asyncio event loop. You write async/await code. If you have blocking or IO-bound operations, you either use AppSync (which runs synchronous lifecycle hooks in a managed thread) or offload work to a thread using self.task_bucket.run_in_thread().
from hassette import App, AppSync
# For mostly async operations (recommended)
class MyAsyncApp(App):
async def on_initialize(self):
await self.api.call_service("light", "turn_on", target={"entity_id": "light.kitchen"})
# For blocking/IO operations
class MySyncApp(AppSync):
def on_initialize_sync(self):
# The bus, scheduler, and API are async — reach their sync facades via .sync
self.api.sync.call_service("light", "turn_on", target={"entity_id": "light.kitchen"})
self.bus.sync.on_state_change("light.kitchen", handler=self.on_change, name="kitchen")
self.scheduler.sync.run_in(self.cleanup, 60, name="cleanup")
def on_change(self, event): ... # pyright: ignore[reportUnusedParameter]
def cleanup(self): ...
# Mixed approach (offload blocking work)
class MyMixedApp(App):
async def on_initialize(self):
# Run blocking code in a thread
result = await self.task_bucket.run_in_thread(self.blocking_work)
def blocking_work(self):
# This runs in a thread pool
return expensive_computation() # pyright: ignore[reportUndefinedVariable]
Access Model
AppDaemon exposes everything via methods on self (the app instance): self.listen_state(...), self.call_service(...), self.run_in(...). All features live on one flat surface.
Hassette uses composition: each subsystem is a separate attribute on the app:
| Attribute | What it does |
|---|---|
self.bus |
Event subscriptions (state changes, service calls, custom events) |
self.scheduler |
Scheduled jobs |
self.api |
Home Assistant REST/WebSocket API calls |
self.states |
Local state cache (automatically updated) |
self.cache |
Persistent disk-backed cache |
self.logger |
Standard Python logger |
Inheritance vs. Composition
AppDaemon apps inherit from Hass (or ADAPI) and call inherited methods directly.
Hassette apps inherit from App (or AppSync), but features are accessed via composition (the subsystem attributes above). The base class provides the lifecycle hooks and wires everything together at startup.
from appdaemon.plugins.hass import Hass
class MyApp(Hass):
def initialize(self):
# Setup code here
pass
from hassette import App
class MyApp(App):
async def on_initialize(self):
# Setup code here (note: async)
pass
Key differences when updating your class definition:
- Change
HasstoApporAppSync - Rename
initialize()toon_initialize()(and addasyncforApp) - Use
awaitfor API calls and other async operations
Typed vs. Untyped
AppDaemon returns raw strings or dicts from API calls. State values are strings; attribute access returns Any. Configuration arguments come in as a plain dictionary (self.args["args"]["key"]).
Hassette uses Pydantic models throughout:
- Entity states are typed objects (e.g.,
LightState,BinarySensorState) with typed attributes - App configuration is a validated Pydantic model — missing fields raise an error at startup, not at runtime
- API responses return structured models instead of raw dicts
Callback Signatures
AppDaemon requires specific callback signatures. State change callbacks must be def my_callback(self, entity, attribute, old, new, **kwargs). Event callbacks must be def my_callback(self, event_name, event_data, **kwargs). Extra keyword arguments you passed when subscribing arrive in **kwargs.
Hassette handlers can have almost any signature. You can:
- Accept the full event object:
async def handler(self, event: CallServiceEvent) - Use dependency injection to extract only the fields you need:
async def handler(self, domain: D.Domain, entity_id: D.EntityId) - Accept no arguments at all:
async def handler(self)
Hassette inspects your handler's type annotations at subscription time and injects the right data automatically. See Bus & Events for the full DI reference.
Synchronous API
If you have existing synchronous code and don't want to add async/await everywhere, use AppSync:
from hassette import AppSync
from hassette.events import RawStateChangeEvent
class MyApp(AppSync):
def on_initialize_sync(self) -> None:
# The bus, scheduler, and API are async — reach their sync facades via .sync
self.api.sync.call_service("light", "turn_on", target={"entity_id": "light.kitchen"})
self.bus.sync.on_state_change("light.kitchen", handler=self.on_change, name="kitchen")
self.scheduler.sync.run_in(self.cleanup, 60, name="cleanup")
def on_change(self, event: RawStateChangeEvent) -> None: ...
def cleanup(self) -> None: ...
AppSync runs its lifecycle hooks in a managed thread. Because the bus, scheduler, and API are async, register and call them through their .sync facades — self.bus.sync, self.scheduler.sync, and self.api.sync. It is a good intermediate step when migrating apps with heavy synchronous logic.
See Also
- Bus & Events — migrating
listen_stateandlisten_eventtobus.on_state_changeandbus.on_call_service - API Calls — migrating
get_state,call_service, andset_state - Dependency Injection — the full dependency injection reference