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.
self.api.helpers is a HelperClient with 4 generic
CRUD methods (list, create, update, delete) covering all 8 domains, plus 3 counter
shortcuts (increment, decrement, reset). Each CRUD method dispatches to the right
domain from its argument — a domain string for list/delete, a typed params model for
create/update — and returns the domain-specific record type.
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.helpers.list("input_boolean"):
if record.id == "vacation_mode":
return record
return await self.api.helpers.create(
CreateInputBooleanParams(name="vacation_mode", initial=False)
)
helpers.list("input_boolean") fetches all input_boolean records from Home Assistant. The loop exits early if a matching id is found, so helpers.create(...) 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 helpers.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 helpers.list("counter"), helpers.create(...), and
helpers.update(...). 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. helpers.increment, helpers.decrement,
and helpers.reset 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 helpers.* methods apply to every domain in
the reference table — only the domain string or params model
type changes.
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.helpers.create(
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 helpers.list("input_boolean") is the only retrieval path if the id is not cached.
List
records: list[InputBooleanRecord] = await self.api.helpers.list("input_boolean")
for record in records:
self.logger.debug("Found input_boolean: id=%s name=%s", record.id, record.name)
helpers.list(domain) returns all records for the domain, regardless of which app created them.
Update
await self.api.helpers.update(
"vacation_mode",
UpdateInputBooleanParams(icon="mdi:palm-tree"),
)
helpers.update(helper_id, params) 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.helpers.delete("input_boolean", "vacation_mode")
helpers.delete(domain, helper_id) returns None. It raises FailedMessageError(code="not_found") if the id
is absent from storage.
All Supported Domains
HelperClient exposes 7 methods. list and delete dispatch on a domain string; create
and update dispatch on the params model's type — passing CreateCounterParams routes the
call to the counter domain and returns a CounterRecord, no domain argument needed.
| Method | Signature | Dispatches on |
|---|---|---|
helpers.list |
list(domain: HelperDomain) -> list[Record] |
domain string |
helpers.create |
create(params: Create*Params) -> Record |
type(params) |
helpers.update |
update(helper_id: str, params: Update*Params) -> Record |
type(params) |
helpers.delete |
delete(domain: HelperDomain, helper_id: str) -> None |
domain string |
helpers.increment |
increment(entity_id: str) -> None |
n/a (counter only) |
helpers.decrement |
decrement(entity_id: str) -> None |
n/a (counter only) |
helpers.reset |
reset(entity_id: str) -> None |
n/a (counter only) |
HelperDomain is the literal union of the 8 supported domain strings: input_boolean,
input_number, input_text, input_select, input_datetime, input_button, counter,
timer. Passing an unsupported string is a type error under Pyright, since HelperDomain
rejects it at the call site before the code ever runs.
@overload declarations on each method narrow the return type per domain — helpers.list("counter")
returns list[CounterRecord], not a generic list[BaseModel]. The params models for
create and update live in hassette.models.helpers (for example
CreateInputBooleanParams, UpdateCounterParams) and need an explicit import at each call
site — see the collapsible reference table below for the full list.
Counter Shortcuts
helpers.increment, helpers.decrement, and helpers.reset 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.helpers.increment(f"counter.{self.cycle_counter_id}")
async def ensure_cycle_counter(self) -> None:
for record in await self.api.helpers.list("counter"):
if record.id == self.cycle_counter_id:
return
await self.api.helpers.create(
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:
records = await harness.api_recorder.helpers.list("input_boolean")
assert len(records) == 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.helpers.list("input_boolean")
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 helpers.list, helpers.create, and helpers.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 (helpers.create and helpers.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