Skip to content

Custom State Classes

You can define custom state classes for domains that aren't included in the core framework. This is useful for:

  • Custom integrations and components in your Home Assistant instance
  • Third-party integrations not yet supported by Hassette
  • Specialized state handling with custom attributes or methods

Basic Custom State Class

To create a custom state class, inherit from one of the base state classes and define a domain field with a Literal type:

from typing import Literal

from hassette.models.states.base import StringBaseState


class MyCustomState(StringBaseState):
    """State class for my_custom_domain entities."""

    domain: Literal["my_custom_domain"]

That's it! The state class notifies the registry upon creation and is immediately available for use. This happens automatically via Python's __init_subclass__ hook — no explicit registration call is required. See State Registry for how automatic registration works.

Choosing a Base Class

Hassette provides several base classes to inherit from, depending on your entity's state value type:

StringBaseState

For entities with string state values (most common):

from typing import Literal

from hassette.models.states.base import StringBaseState


class LauncherState(StringBaseState):
    domain: Literal["launcher"]

NumericBaseState

For entities with numeric state values - stored as Decimal internally (supports int, float, Decimal):

from typing import Literal

from hassette.models.states.base import NumericBaseState


class CustomSensorState(NumericBaseState):
    domain: Literal["custom_sensor"]

BoolBaseState

For entities with boolean state values (True/False, automatically converts "on"/"off"):

from typing import Literal

from hassette.models.states.base import BoolBaseState


class CustomBinaryState(BoolBaseState):
    domain: Literal["custom_binary"]

DateTimeBaseState

For entities with datetime state values (supports ZonedDateTime, PlainDateTime, Date):

from typing import Literal

from hassette.models.states.base import DateTimeBaseState


class TimestampState(DateTimeBaseState):
    domain: Literal["timestamp"]

TimeBaseState

For entities with time-only state values:

from typing import Literal

from hassette.models.states.base import TimeBaseState


class TimeOnlyState(TimeBaseState):
    domain: Literal["time_only"]

Define your own

For entities with state values that don't fit the predefined base classes, you can inherit directly from BaseState and provide the type parameter for the state value and value_type class variable:

from enum import StrEnum
from typing import Any, ClassVar, Literal

from hassette.models.states.base import BaseState


class MyValueType(StrEnum):
    OPTION_A = "option_a"
    OPTION_B = "option_b"
    OPTION_C = "option_c"


class MyCustomState(BaseState[MyValueType]):
    domain: Literal["my_custom_domain"]

    value_type: ClassVar[type[Any] | tuple[type[Any], ...]] = (
        MyValueType,
        type(None),
    )

The value_type class variable is used by Hassette to validate state values at runtime. It should include all acceptable types for the state value, including None if the state can be unset.

Adding Custom Attributes

You can define custom attributes specific to your domain by creating an attributes class:

from typing import Literal

from pydantic import Field

from hassette.models.states.base import AttributesBase, StringBaseState


class RedditAttributes(AttributesBase):
    """Attributes for Reddit entities."""

    subreddit: str | None = Field(default=None)
    post_count: int | None = Field(default=None)
    karma: int | None = Field(default=None)


class RedditState(StringBaseState):
    """State class for reddit domain entities."""

    domain: Literal["reddit"]
    attributes: RedditAttributes  # Override attributes type

Using Custom States in Apps

Once defined, custom state classes work with all of Hassette's APIs:

Via get_states()

from hassette import App

from .my_states import RedditState  # pyright: ignore[reportMissingImports]


class MyApp(App):
    async def on_initialize(self):
        # Get all reddit entities
        reddit_states = self.states[RedditState]

        for entity_id, state in reddit_states:
            print(f"{entity_id}: {state.value}")
            if state.attributes.karma:
                print(f"  Karma: {state.attributes.karma}")

With Dependency Injection

from typing import Annotated

from hassette import A, App, D

from .my_states import RedditState  # pyright: ignore[reportMissingImports]


class MyApp(App):
    async def on_initialize(self):
        await self.bus.on_state_change("reddit.my_account", handler=self.on_reddit_change, name="reddit_account")

    async def on_reddit_change(self, new_state: D.StateNew[RedditState], karma: Annotated[int | None, A.get_attr_new("karma")]):
        self.logger.info("New karma: %d", karma or 0)

Direct API Access

from hassette import App

from .my_states import RedditState  # pyright: ignore[reportMissingImports]


class MyApp(App):
    async def on_initialize(self):
        reddit_state = await self.api.get_state("reddit.my_account")
        assert isinstance(reddit_state, RedditState)
        if reddit_state.attributes.subreddit:
            print(f"Subreddit: {reddit_state.attributes.subreddit}")

Runtime vs Type-Time Access

For known domains (defined in Hassette or in the .pyi stub), you can use property-style access:

from hassette import App


class MyApp(App):
    async def on_initialize(self):
        # Known domains (autocomplete works)
        for entity_id, light in self.states.light:
            print(light.attributes.brightness)

For custom domains, use states[<class>] for full type checking:

from typing import Literal

from hassette import App
from hassette.models.states.base import StringBaseState


class MyCustomState(StringBaseState):
    domain: Literal["my_custom_domain"]


class MyApp(App):
    async def on_initialize(self):
        # Custom domains (use states[<class>] for typing)
        custom_states = self.states[MyCustomState]
        for entity_id, state in custom_states:
            print(state.value)
from hassette import App


class MyApp(App):
    async def on_initialize(self):
        # Works at runtime but static analysis sees BaseState
        for entity_id, state in self.states.my_custom_domain:
            print(state.value)  # state is typed as BaseState

Complete Example

Here's a complete example with a custom integration:

# my_states.py
from typing import Literal

from pydantic import Field

from hassette import App, D
from hassette.models.states.base import AttributesBase, StringBaseState


class ImageAttributes(AttributesBase):
    """Attributes for image entities."""

    url: str | None = Field(default=None)
    width: int | None = Field(default=None)
    height: int | None = Field(default=None)
    content_type: str | None = Field(default=None)


class ImageState(StringBaseState):
    """State class for image domain."""

    domain: Literal["image"]
    attributes: ImageAttributes


class ImageMonitorApp(App):
    async def on_initialize(self):
        # Monitor all image entities
        await self.bus.on_state_change(
            entity_id="image.*",
            handler=self.on_image_change,  # Glob pattern
            name="image_monitor",
        )

    async def on_image_change(
        self,
        new_state: D.StateNew[ImageState],
        entity_id: D.EntityId,
    ):
        attrs = new_state.attributes
        self.logger.info(
            "Image %s updated: %dx%d, %s",
            entity_id,
            attrs.width or 0,
            attrs.height or 0,
            attrs.content_type or "unknown",
        )

Best Practices

  1. One domain per state class - Each state class should handle exactly one domain. Mixing domains in one class breaks the registry lookup, which maps one domain string to exactly one class.
  2. Use Literal for domain - Always use Literal["domain_name"] to enable auto-registration. A plain str annotation does not carry a value at class definition time, so the registry cannot extract the domain name automatically.
  3. Choose the right base class - Match the base class to your entity's state value type
  4. Document your attributes - Add docstrings to custom attribute classes
  5. Use typing - Use type hints throughout for better IDE support and type checking

Troubleshooting

State class not registering

If your custom state class isn't being recognized:

  1. Check the domain field - Ensure you have domain: Literal["your_domain"]
  2. Call super().__init_subclass__() - If you override __init_subclass__, call super().__init_subclass__() so registration still happens
  3. Check for errors - Look for registration errors in debug logs

Type hints not working

If IDE autocomplete isn't working:

  1. Use states[<class>] - For custom domains, use self.states[CustomState]

State conversion fails

If state conversion is failing:

  1. Check the base class - Ensure it matches your entity's state value type
  2. Validate attributes - Make sure custom attributes use proper Pydantic field types
  3. Check Home Assistant data - Verify the actual state data structure from Home Assistant

See Also