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 are visible in the HA UI. Apps that
want to self-provision their own helpers (a vacation-mode toggle, a motion-event cycle
counter, a user-facing mode selector) can create and manage them directly through typed
Api methods. The full API is 32 CRUD methods covering 8 domains, plus 3 counter
service-call shortcuts.
Typed Models
Each helper 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 (all fields optional) | "ignore" — extra fields from round-tripped records are silently dropped |
All three CRUD methods that accept a params object serialize it with
model_dump(exclude_unset=True), not exclude_none. This means omitting a field and
explicitly setting it to None produce different wire payloads — see
Gotchas for the full implications.
Creating a Helper
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 (usually the slugified
form of the name you passed, e.g. "vacation_mode"). Store or log it if you need it
later — list_input_booleans() is the way to retrieve it again.
Listing Helpers
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)
Updating a Helper
update_* accepts a helper_id (the stored id field, not the display name) and a
partial params object. Only the fields you pass are sent to HA:
await self.api.update_input_boolean(
"vacation_mode",
UpdateInputBooleanParams(icon="mdi:palm-tree"),
)
Passing helper_id that does not exist raises FailedMessageError(code="not_found").
Deleting a Helper
await self.api.delete_input_boolean("vacation_mode")
Returns None. Raises FailedMessageError(code="not_found") if the id is absent.
Idempotent Bootstrap (the Simple Pattern)
Your app might not know whether it has been run before and whether its helper already exists. The correct pattern is a short list-then-create loop:
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)
)
This pattern is correct when one app in the deployment owns provisioning for this
helper — which is the recommended topology. Call it from on_initialize and keep the
returned record for the rest of the app's lifetime.
Concurrent provisioning
If two apps can run _ensure_vacation_mode simultaneously, both may pass the
list-then-create gap and both will succeed — but HA will silently auto-suffix the
second helper's id to vacation_mode_2. There is no error code to catch; see
Gotchas for the full explanation and the recommended mitigation (naming
discipline, not retry logic).
Counter Service-Call 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. Call them through api.call_service directly:
await self.api.call_service("timer", "start", target={"entity_id": "timer.away_mode"})
The asymmetry is intentional. Counter increment/decrement/reset are high-frequency
operations that benefit from short, readable call sites. Timer actions are typically
one-off and the full call_service signature makes the intent explicit.
Testing with the Harness
AppTestHarness exposes a seed_helper(record) method that pre-populates the harness's
helper store. The harness derives the helper domain from the record's class, so there is
no domain parameter — just pass the typed record.
from hassette.models.helpers import InputBooleanRecord
from hassette.test_utils import AppTestHarness
from myapp import VacationModeApp # pyright: ignore[reportMissingImports]
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, so later mutations to the record you passed in won't leak into harness state.
Gotchas
-
HA auto-suffixes on name collision. When you call
create_input_boolean(or anycreate_*) with anamethat slugifies to anidalready in storage, HA does not raise an error. Home Assistant's collection storage silently appends_2,_3, and so on until it finds a free slot. Two concurrent creators of the same-named helper will both succeed, leaving two semantically-duplicate records in storage. There is noname_in_useerror code to catch. The correct mitigation is naming discipline: prefix every helper with an identifier unique to its owning app (e.g.,motionapp_cyclesrather thancycles) so collisions cannot happen in the first place, and ensure only one app ever provisions any given helper. -
CreateInputDatetimeParamsrequireshas_date=Trueorhas_time=True. BothFalseraisesValidationErrorat construction time — before any network call is made.UpdateInputDatetimeParamsdoes not enforce this constraint on partial updates because the counterpart field stays at its stored value. -
exclude_unset=Truevs explicitNone. All CRUD methods serialize params withmodel_dump(exclude_unset=True). A field you omit entirely is not sent to HA (HA keeps its stored value). A field you pass asNoneis sent asnull, which may clear the value. These produce different behavior: if you want to leaveiconunchanged, omit it from the constructor; if you want to clear it, passicon=None. -
CounterRecordandCounterStateare two different models. Reading the current counter value at runtime usesawait self.api.get_state("counter.mycounter"), which returns aCounterState. Changing the counter's configuredinitialvalue usesawait self.api.update_counter("mycounter", UpdateCounterParams(initial=0)), which returns aCounterRecord. Changes to stored config take effect on the next HA restart;increment_counter/decrement_counter/reset_counterare immediate but do not change stored config. -
Helper creation persists across HA restarts. HA stores helpers in
.storage/, unlike volatile entity state. A helper you create inon_initializetoday will still be there next week. The idempotent-bootstrap pattern above exists precisely because of this: on the second run your helper is already there. -
RetryableConnectionClosedErroris a second exception class callers may receive. A WebSocket disconnect mid-CRUD propagates asRetryableConnectionClosedError, notFailedMessageError. Callers whoseexcept FailedMessageErrorblock contains cleanup logic should add a separateexcept (FailedMessageError, RetryableConnectionClosedError):or wrap in a broaderexcept Exception:where appropriate.
Not Included / Out of Scope
- Subscribe commands. Hassette does not currently expose a typed wrapper for HA's
helper config-change subscribe commands. Apps that need to react to stored-config
changes in real time should subscribe to entity state changes instead, or fall back to
raw
ws_send_and_wait().
See Also
- API Reference —
hassette.api.Api— full method signatures for all 32 CRUD methods and 3 counter shortcuts - Testing Your Apps — general harness documentation