Managing Helpers
Home Assistant helpers (input_boolean, input_number, input_text, input_select,
input_datetime, input_button, counter, timer) are persistent entities stored in
HA's .storage/ directory. They survive restarts and appear in the HA UI. The
Api exposes 32 typed CRUD methods across 8 domains, plus 3 counter
shortcuts.
Creating a Helper on Startup
The most common pattern provisions a helper once during on_initialize (the app startup hook), then holds the
returned record — a Pydantic model with the helper's id, name, and configuration — for the app's lifetime. Because helpers persist across restarts, the
idempotent approach checks for an existing record before creating:
async def ensure_vacation_mode(self) -> InputBooleanRecord:
for record in await self.api.list_input_booleans():
if record.id == "vacation_mode":
return record
return await self.api.create_input_boolean(
CreateInputBooleanParams(name="vacation_mode", initial=False)
)
list_input_booleans() fetches all input_boolean records from Home Assistant. The loop exits early if a matching id is found, so create_input_boolean only runs on first startup.
Concurrent provisioning
When two apps run the same list-then-create sequence simultaneously, both may pass
the gap between list and create. HA does not raise an error. It silently appends _2
to the second helper's id. No error code signals the collision. The correct mitigation
is naming discipline: each helper's name should carry a prefix unique to its owning app
(for example, motionapp_cycles rather than cycles), and only one app should ever
provision a given helper.
Common Pitfalls
HA auto-suffixes on name collision. When create_* receives a name that
slugifies to an id already in storage, HA does not raise an error. It silently
appends _2, _3, and so on until it finds a free slot. Two concurrent creators of
the same-named helper both succeed, leaving two semantically-duplicate records. There
is no name_in_use error code to catch. Each helper's name should carry a prefix
unique to its owning app, and only one app should provision it.
CreateInputDatetimeParams requires has_date=True or has_time=True. Both
fields False raises ValidationError at construction time, before any network call.
UpdateInputDatetimeParams does not enforce this constraint on partial updates, because
the counterpart field retains its stored value.
exclude_unset=True vs explicit None. All CRUD methods serialize params with
model_dump(exclude_unset=True). A field omitted from the constructor is not sent to
HA; HA keeps its stored value. A field passed as None is sent as null, which may
clear the value on the HA side. Omitting icon and passing icon=None produce
different wire payloads.
CounterRecord and CounterState are two different models. CounterRecord
represents stored configuration, returned by list_counters, create_counter, and
update_counter. CounterState represents the live runtime value, returned by
get_state("counter.mycounter"). Changes to stored config (for example, updating
initial) take effect after an HA restart. increment_counter, decrement_counter,
and reset_counter are immediate but do not modify stored config.
Helper creation persists across HA restarts. HA stores helpers in .storage/.
A helper created during on_initialize is still present on the next run. The
idempotent bootstrap pattern in Creating a Helper on Startup
exists for this reason.
RetryableConnectionClosedError is a second exception class callers may receive.
A WebSocket disconnect mid-CRUD propagates as RetryableConnectionClosedError, not
FailedMessageError. Exception handlers that target only FailedMessageError miss
this case. A broader except clause covering both exception types handles it
correctly.
CRUD Operations
The create, list, update, and delete pattern is identical across all 8 domains. The
examples below use input_boolean; the same method names apply to every domain in the
reference table.
Create
from hassette import App, AppConfig
from hassette.models.helpers import CreateInputBooleanParams, InputBooleanRecord
class VacationModeApp(App[AppConfig]):
async def on_initialize(self) -> None:
record: InputBooleanRecord = await self.api.create_input_boolean(
CreateInputBooleanParams(name="vacation_mode", initial=False)
)
self.logger.info("Provisioned vacation_mode helper: %s", record.id)
The returned InputBooleanRecord carries the id HA assigned, typically the slugified
form of the name passed in, for example "vacation_mode". Storing or logging the id
is useful, as list_input_booleans() is the only retrieval path if the id is not cached.
List
records: list[InputBooleanRecord] = await self.api.list_input_booleans()
for record in records:
self.logger.debug("Found input_boolean: id=%s name=%s", record.id, record.name)
list_* returns all records for the domain, regardless of which app created them.
Update
await self.api.update_input_boolean(
"vacation_mode",
UpdateInputBooleanParams(icon="mdi:palm-tree"),
)
update_input_boolean accepts a helper_id string (the stored id field, not the
display name) and a partial params object. Only fields present in the params object are
sent to HA; absent fields retain their stored values. A helper_id that does not exist
raises FailedMessageError(code="not_found").
Delete
await self.api.delete_input_boolean("vacation_mode")
delete_* returns None. It raises FailedMessageError(code="not_found") if the id
is absent from storage.
All Supported Domains
The pattern above applies to every domain. Method names follow the same convention:
| Domain | List | Create | Update | Delete |
|---|---|---|---|---|
input_boolean |
list_input_booleans |
create_input_boolean |
update_input_boolean |
delete_input_boolean |
input_number |
list_input_numbers |
create_input_number |
update_input_number |
delete_input_number |
input_text |
list_input_texts |
create_input_text |
update_input_text |
delete_input_text |
input_select |
list_input_selects |
create_input_select |
update_input_select |
delete_input_select |
input_datetime |
list_input_datetimes |
create_input_datetime |
update_input_datetime |
delete_input_datetime |
input_button |
list_input_buttons |
create_input_button |
update_input_button |
delete_input_button |
counter |
list_counters |
create_counter |
update_counter |
delete_counter |
timer |
list_timers |
create_timer |
update_timer |
delete_timer |
Counter Shortcuts
increment_counter, decrement_counter, and reset_counter operate on the live entity
state, not stored configuration. They call HA's counter service domain and take effect
immediately:
from hassette import App, AppConfig
from hassette.models.helpers import CreateCounterParams
class MotionCycleApp(App[AppConfig]):
cycle_counter_id: str = "motionapp_cycles"
async def on_initialize(self) -> None:
await self.ensure_cycle_counter()
await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_motion,
name="motion_cycle",
)
async def on_motion(self) -> None:
await self.api.increment_counter(f"counter.{self.cycle_counter_id}")
async def ensure_cycle_counter(self) -> None:
for record in await self.api.list_counters():
if record.id == self.cycle_counter_id:
return
await self.api.create_counter(
CreateCounterParams(name=self.cycle_counter_id, initial=0)
)
Timer actions (timer.start, timer.pause, timer.cancel) are not wrapped as
shortcuts. They go through call_service directly:
await self.api.call_service("timer", "start", target={"entity_id": "timer.away_mode"})
Counter shortcuts are high-frequency operations. The shorter call site makes a difference
when a handler runs on every motion event. Timer actions are typically one-off; the full
call_service signature makes the intent explicit at those call sites.
Testing
AppTestHarness exposes a seed_helper(record) method that pre-populates the harness's
helper store. The harness derives the domain from the record's class, so no domain
parameter is needed. The typed record is sufficient:
from hassette.models.helpers import InputBooleanRecord
from hassette.test_utils import AppTestHarness
from myapp import VacationModeApp
async def test_vacation_mode_creates_helper_on_first_run():
async with AppTestHarness(VacationModeApp, config={}) as harness:
harness.api_recorder.assert_call_count("create_input_boolean", 1)
async def test_list_returns_seeded_helper():
async with AppTestHarness(VacationModeApp, config={}) as harness:
harness.seed_helper(
InputBooleanRecord(id="vacation_mode", name="Vacation Mode", initial=False)
)
records = await harness.api_recorder.list_input_booleans()
assert len(records) == 1
assert records[0].name == "Vacation Mode"
Seeded records are stored as deep copies. Later mutations to the record passed into
seed_helper do not affect harness state.
Typed model reference
Each domain exposes three Pydantic model classes in hassette.models.helpers:
| Model | Purpose | extra policy |
|---|---|---|
{Domain}Record |
Stored configuration returned by list_*, create_*, and update_* |
"allow": unknown HA fields pass through |
Create{Domain}Params |
Required and optional fields for a create call | "forbid": typos raise ValidationError at construction |
Update{Domain}Params |
Partial update payload with all fields optional | "ignore": extra fields from round-tripped records are silently dropped |
The two CRUD methods that accept a params object (create_* and update_*) serialize it with
model_dump(exclude_unset=True), not exclude_none. Omitting a field and explicitly
setting it to None produce different wire payloads.
See Also
- API Overview: when to use
self.apivsself.states - API Methods:
call_servicefor timer actions and other service calls - Testing Apps: full harness documentation
- Apps: lifecycle hooks including
on_initialize