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