Migration Checklist
Use this checklist when migrating each app from AppDaemon to Hassette. Work through one app at a time. Verify it works before moving to the next.
The Migration Guide overview covers pre-migration setup (installing Hassette, reviewing the mental model). This checklist picks up from there.
Before You Start
- [ ] Requires Python 3.11 or later. Check with
python --versionorpython3 --version. Hassette will not install on 3.10 or earlier. See python.org/downloads to upgrade.
Step 1: Configuration
- [ ] Convert
appdaemon.yamlconnection settings tohassette.toml[hassette]section - [ ] Convert each app entry in
apps.yamlto an[apps.your_app]table inhassette.toml - [ ] Create a typed
AppConfigsubclass for each app. Move allself.args["args"]["key"]accesses toself.app_config.key - [ ] Verify required fields raise a clear error if missing (run the app without a required config key)
See Configuration for the full conversion guide.
Step 2: App Structure
- [ ] Change base class from
Hass(orADAPI) to App (async) orAppSync(sync). Rule of thumb: if your callbacks never block (notime.sleep, norequests.get), useApp; if they do and you can't convert them yet, useAppSync. - [ ] If you chose
AppSync: use the.syncfacades everywhere in steps 3–5 —self.bus.sync.on_state_change(...),self.scheduler.sync.run_in(...),self.api.sync.call_service(...)— with noawait. Calling the async methods from sync hooks silently does nothing. - [ ] Rename
initialize()to the correct hook for your base class:App:async def on_initialize(self). Must beasync def.AppSync:def on_initialize_sync(self). Must be a plain synchronous method. Do not overrideon_initializeonAppSync(it is@final; overriding raisesCannotOverrideFinalErrorat class definition time).
- [ ] If you have
terminate(), rename it:App:async def on_shutdown(self)AppSync:def on_shutdown_sync(self)
- [ ] Confirm the app starts without errors (
uv run hassette runor your start command)
See Mental Model for the lifecycle differences.
Step 3: Event Listeners
- [ ] Convert each
self.listen_state(...)toawait self.bus.on_state_change(...) - [ ] Add
await. Allself.bus.on_*()methods are async —awaitonly works insideasync defmethods, so check the surrounding method too. - [ ] Add
name=. A stable string identifier is required on every registration (e.g.name="kitchen_light"). - [ ] Move filter arguments:
new=→changed_to=,old=→changed_from= - [ ] Update callback signatures to use dependency injection or accept an event object
- [ ] Replace
self.cancel_listen_state(handle)withsubscription.cancel()— registration returns aSubscription; store it (sub = await self.bus.on_state_change(...)) to cancel later - [ ] Convert each
self.listen_event("call_service", ...)toawait self.bus.on_call_service(...) - [ ] Add
awaitandname= - [ ] Update callback signatures
- [ ] Replace
self.cancel_listen_event(handle)withsubscription.cancel() - [ ] For attribute-level subscriptions, switch to
await self.bus.on_attribute_change(...)
See Bus & Events for side-by-side examples.
Step 4: Scheduler
- [ ] Convert each
self.run_in(cb, seconds)toawait self.scheduler.run_in(cb, delay=seconds) - [ ] Convert each
self.run_once(cb, time(H, M))toawait self.scheduler.run_once(cb, at="HH:MM") - [ ] Convert each
self.run_every(cb, "now", interval)toawait self.scheduler.run_every(cb, seconds=interval) - [ ] Convert each
self.run_daily(cb, time(H, M))toawait self.scheduler.run_daily(cb, at="HH:MM") - [ ] Replace
self.cancel_timer(handle)withjob.cancel()— each scheduling call returns aScheduledJob; store it (job = await self.scheduler.run_in(...)) to cancel later - [ ] Check for blocking work inside callbacks. For apps with heavy sync logic, switch to
AppSync. For isolated blocking calls inside anApphandler, useawait self.task_bucket.run_in_thread(...).
See Scheduler for method equivalents.
Step 5: API Calls
- [ ] Convert
self.get_state(entity_id)to domain access on the state cache for cached reads —self.get_state("light.kitchen")becomesself.states.light.get("light.kitchen") - [ ] Replace
self.call_service("domain/service", ...)withawait self.api.call_service("domain", "service", ...) - [ ] Add
awaitto allself.api.*calls. Forgettingawaitmeans the call never runs — no error, just silence. If a service call appears to do nothing, check for a missingawait. - [ ] Replace
self.set_state(...)withawait self.api.set_state(...) - [ ] Replace
self.log(...)withself.logger.info(...)(and.warning(),.error()as needed)
See API Calls for the full guide.
Step 6: Test
This step is optional for the initial migration but worth it — AppDaemon has no testing story, so this is new ground. The Testing page walks through it from scratch.
- [ ] Write at least one test using
AppTestHarness - [ ] Seed entity state before simulating events
- [ ] Simulate the key events your app responds to
- [ ] Assert the expected API calls were made via
harness.api_recorder - [ ] Run the test suite:
pytest
See Testing for the test harness guide.
Step 7: Verify Live
- [ ] Deploy the migrated app alongside (or instead of) the AppDaemon version
- [ ] Confirm all automations fire as expected in live operation
- [ ] Check logs for any runtime errors or unexpected behavior
Common Pitfalls
Async gotchas
- Forgetting
awaitonself.api.*calls is the most common migration mistake. The call returns a coroutine object and silently does nothing — see Async Basics for the full explanation. - Every
self.bus.on_*()call requiresname=. Omitting it raisesListenerNameRequiredErrorat runtime. - In
AppSync, use.syncfacades for bus, scheduler, and API:self.bus.sync.on_state_change(...),self.scheduler.sync.run_in(...),self.api.sync.call_service(...). Calling the async methods from sync hooks returns un-awaited coroutines that silently do nothing. - Do not use
.syncfacades insideApplifecycle methods. Use the async API instead, or switch toAppSync.
The two APIs you'll use most often after migration are configuration and state access. Both are shorter than their AppDaemon equivalents.
Quick-reference: configuration and state access
Configuration
- AppDaemon:
self.args["args"]["key"] - Hassette:
self.app_config.key - Define all config keys in your
AppConfigmodel for validation and autocomplete.
State
- AppDaemon:
self.get_state()returns a cached state (string or dict) - Hassette:
self.states.light.get("light.kitchen")returns a typed cached state. Noawaitneeded. The domain prefix is optional. - Use
self.api.get_state()only when you need a fresh read from Home Assistant.
Next Steps
After migrating all your apps:
- Review the Core Concepts to learn the full Hassette feature set
- Explore Dependency Injection, Custom States, and State Conversion
- Set up the Web UI for live monitoring of your automations