Skip to content

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  # 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. 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

All three CRUD methods that accept a params object 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.api vs self.states
  • API Methods: call_service for timer actions and other service calls
  • Testing Apps: full harness documentation
  • Apps: lifecycle hooks including on_initialize