Mental Model
This page maps the design differences between AppDaemon and Hassette so you can write idiomatic Hassette code instead of translating patterns one-for-one.
App Structure
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
Three things change:
- Base class:
HassbecomesApp[Config]. The generic parameter is optional. App with no type argument works fine. - Lifecycle hook:
initialize()becomeson_initialize(). - Async keyword: Hassette's hook is
async def. The body usesawait.
Access Model
AppDaemon puts everything on self. self.listen_state(...), self.call_service(...), self.run_in(...) all live on one flat surface.
Hassette uses composition. Each subsystem is a separate attribute:
| Attribute | What it does |
|---|---|
self.bus |
Subscribe to state changes, service calls, and custom events |
self.scheduler |
Schedule jobs by delay, interval, time, or cron expression |
self.api |
Call Home Assistant REST and WebSocket APIs |
self.states |
Read local state cache, automatically kept current |
self.cache |
Persistent disk-backed key-value store |
self.logger |
Standard Python logger scoped to the app |
The upside is discoverability. Typing self.bus. in your editor gives you the full event API. Typing self.scheduler. gives you the scheduler. Nothing is buried.
Async vs Sync
AppDaemon is multi-threaded. Each app runs in its own thread, so synchronous code works fine.
Hassette runs all apps in a single asyncio event loop. Two things follow:
- API calls and bus registrations require
await— the practical rule: putawaitin front of any call toself.api,self.bus, orself.scheduler, and declare the surrounding methodasync def. Reads fromself.statesare synchronous. - Blocking the event loop (a long
time.sleep, a slow synchronous database call) blocks all apps, not just yours.
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]
The example's self.task_bucket.run_in_thread(...) is a helper on every App instance that runs blocking code in a thread without stalling other apps. If most of your code is blocking and you cannot convert it, use AppSync (described below).
Typed vs Untyped
AppDaemon returns raw strings and dicts. self.get_state("light.kitchen") returns "on" or "off". Attribute access returns Any. Configuration lives in self.args, a plain dict.
Hassette uses typed models throughout — objects with named, validated fields instead of raw dicts (powered by Pydantic).
Entity states are typed objects. self.states.get("light.kitchen") returns a LightState with typed fields. Your IDE knows the shape, and a type checker like Pyright catches typos at development time, not at 2am.
App configuration is a validated Pydantic model. You declare fields with types and defaults; Hassette loads and validates them at startup. A missing required field raises an error before any handler fires.
API responses return structured models instead of raw dicts. You work with attributes, not string keys.
Callback Signatures
AppDaemon requires a fixed signature. State change callbacks must be:
def my_callback(self, entity, attribute, old, new, **kwargs): ...
You always receive all five arguments, whether you need them or not.
Hassette handlers can have almost any signature. Three styles work:
Full event object. Receive the raw event and extract what you need:
async def on_light_change(self, event: RawStateChangeEvent): ...
Dependency injection. Annotate parameters with D.* types and Hassette fills them in:
async def on_light_change(self, new_state: D.StateNew[states.LightState]): ...
No arguments. Use when you only care that the event fired:
async def on_motion(self): ...
Hassette inspects your handler's type annotations at subscription time and injects the right data automatically. See Dependency Injection for the full reference.
Synchronous API (AppSync)
If you have a large synchronous codebase and don't want to convert everything at once, AppSync is an intermediate step. It runs lifecycle hooks in a managed thread, letting you write synchronous code as before.
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: ...
Because the bus, scheduler, and API are async internally, AppSync exposes synchronous wrappers: self.bus.sync, self.scheduler.sync, self.api.sync. Each one waits for the async operation to finish and returns the result to your synchronous code.
AppSync keeps your existing code working while you migrate. As you convert methods to async, you can move them to App incrementally.
See Also
Bus& Events: migratinglisten_stateandlisten_event- API Calls: migrating
get_state,call_service, andset_state - Dependency Injection: full DI reference