Skip to content

State Conversion

Home Assistant sends state data as untyped dicts with string values. Two registries cooperate to produce typed Python objects: the StateRegistry maps domains to state classes, and the TypeRegistry converts string values to typed Python values. This conversion runs automatically whenever a handler receives state via dependency injection — the mechanism that fills in handler parameters like D.StateNew[T] from the event. Most apps benefit from it without touching either registry directly.

The registries become relevant when overriding domain mappings, registering custom converters, or debugging unexpected types.

The Conversion Pipeline

When state data arrives from Home Assistant, StateRegistry.try_convert_state() runs the full pipeline. Dependency injection calls it automatically; direct calls are only needed when converting raw dicts outside a handler, such as in tests or data scripts. Given this raw input:

state_dict = {
    "entity_id": "binary_sensor.front_door",
    "state": "on",  # String from HA
}

The pipeline runs five steps:

  1. StateRegistry.resolve(domain="binary_sensor") looks up the registered class for the domain. It returns BinarySensorState.

  2. Pydantic validation begins on BinarySensorState.

  3. The _validate_domain_and_state model validator reads value_type from the class and delegates to TypeRegistry.

  4. TypeRegistry looks up the (str, bool) converter and converts "on" to True.

  5. Validation completes. The result is a fully typed state object:

from hassette import STATE_REGISTRY

state_dict = {
    "entity_id": "binary_sensor.front_door",
    "state": "on",
}
door_state = STATE_REGISTRY.try_convert_state(state_dict)
# Result: BinarySensorState with value=True

StateRegistry answers "which class?". TypeRegistry answers "which type for the value?". Each state class declares a value_type class variable — the type (or tuple of types) the value field should hold. TypeRegistry reads this and selects the right converter:

from typing import Any, ClassVar, Literal

from hassette.models.states import BaseState


class BoolBaseState(BaseState[bool | None]):
    """Base class for boolean states.

    Valid state values are True, False, or None.
    Converts the strings "on" and "off" to True and False.
    """

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


class BinarySensorState(BoolBaseState):
    """Representation of a Home Assistant binary_sensor state.

    See: https://www.home-assistant.io/integrations/binary_sensor/
    """

    domain: Literal["binary_sensor"]

When resolve returns None for an unregistered domain, try_convert_state falls back to BaseState.

Domain-to-Class Mapping

How Registration Works

Any class that inherits from BaseState and declares a domain: Literal["domain_name"] field registers itself automatically at class definition time. No explicit call is needed.

BaseState.__init_subclass__ runs when Python evaluates the class body. It calls get_domain(), which reads the Literal type argument from the domain annotation, and records the class under that domain. Classes without a Literal["..."] annotation on domain are silently skipped.

from typing import Literal

from hassette.models.states import BaseState


class LightAttributes(BaseState):  # simplified for example
    pass


class LightState(BaseState):
    """State model for light entities."""

    domain: Literal["light"]
    attributes: LightAttributes

Domain Lookup

StateRegistry.resolve(domain=...) returns the registered class for a domain, or None when no class is registered.

from hassette import STATE_REGISTRY

# Get class for a domain
state_class = STATE_REGISTRY.resolve(domain="light")
# Returns: LightState

The None return is intentional. try_convert_state handles the fallback to BaseState when resolve returns None.

Overriding a Domain Mapping

A custom class with the same Literal domain as a built-in replaces the existing mapping. Overriding is how custom attributes get typed — for example, a sensor integration that reports a calibration field not present on the built-in SensorState. The override takes effect at class definition time.

from typing import Literal

from hassette.models.states import SensorAttributes, SensorState


class CustomSensorAttributes(SensorAttributes):
    custom_field: str | None = None


class CustomSensorState(SensorState):
    """Extended sensor state with custom attributes."""

    domain: Literal["sensor"]
    attributes: CustomSensorAttributes

The registry replaces the previous mapping silently and globally — a typo in the Literal domain overrides a built-in with no warning. STATE_REGISTRY.resolve(domain="sensor") confirms which class is registered. All subsequent state events for sensor entities produce CustomSensorState instances.

For classes that can't declare a Literal domain — built dynamically, or registered conditionally at runtime — register_state_converter registers a class with the registry explicitly. It is the imperative equivalent of the Literal-based auto-registration.

STATE_REGISTRY is available as a top-level import for direct access outside an app: from hassette import STATE_REGISTRY.

Union Type Support

A handler can accept multiple entity types at once with a union annotation. StateRegistry resolves the union by matching each type's domain against the incoming entity's domain.

from hassette import App, D, states


class SensorApp(App):
    async def on_sensor_change(self, new_state: D.StateNew[states.SensorState | states.BinarySensorState]):
        # StateRegistry determines the correct type based on domain
        if new_state.domain == "sensor" and new_state.value:
            # new_state is SensorState
            float(new_state.value)
        else:
            # new_state is BinarySensorState
            pass

For D.StateNew[states.SensorState | states.BinarySensorState], the DI system extracts the domain from the entity ID, checks each type in the union, and selects the one whose Literal domain matches. When no type matches, conversion falls back to BaseState.

Value Conversion

How It Works

TypeRegistry maps (from_type, to_type) pairs to converter functions. When a raw value does not match the expected value_type, the registry looks up a matching converter and applies it.

from hassette import TYPE_REGISTRY

# Convert a value
result = TYPE_REGISTRY.convert("42", int)  # Returns 42 as int

When no registered converter exists, the registry tries the target type's constructor as a fallback. A successful constructor call auto-registers the pair for future calls.

For union value_type declarations (value_type = (int, float, str)), conversion is attempted in order and the first success wins. str succeeds trivially (no conversion needed), so placing it first would always short-circuit before attempting int or float. The most specific type must come first: (int, float, str) is correct; (str, int, float) is not.

Built-in Converters

Numeric

From To Notes
str int Direct parse
str float Direct parse
str Decimal High-precision parse
float Decimal Precision-preserving
Decimal int Truncates fractional part
Decimal float Precision loss accepted
int float Widening conversion
float int Truncates fractional part

Boolean

The strbool converter maps Home Assistant string values:

  • True: "on", "true", "yes", "1"
  • False: "off", "false", "no", "0"

The boolstr converter produces Python's "True" or "False", not HA format.

DateTime

All datetime conversions use the whenever library, which ships with Hassette.

whenever types:

From To Method
str ZonedDateTime Parses ISO, plain, or date-only strings (date-only assumes system timezone)
str Date Date.parse_iso
str Time Time.parse_iso
str OffsetDateTime OffsetDateTime.parse_iso
str PlainDateTime PlainDateTime.parse_iso
ZonedDateTime Instant to_instant()
ZonedDateTime PlainDateTime to_plain()
ZonedDateTime str format_iso()
Time str format_iso()

Stdlib datetime types (for boundary compatibility):

From To Method
str datetime Via ZonedDateTime then py_datetime()
str time Via Time.parse_iso().py_time()
str date Via Date.parse_iso().py_date()
Time time py_time()

Custom Converters

Decorator Registration

@register_type_converter_fn registers a converter by reading from_type and to_type from the function's type annotations. The parameter must be named value; the return annotation determines the target type.

from enum import StrEnum, auto
from typing import Annotated

from hassette import A, App, register_type_converter_fn


class Effect(StrEnum):
    BLINK = auto()
    BREATHE = auto()
    CANDLE = auto()
    CHANNEL_CHANGE = auto()
    COLORLOOP = auto()
    FINISH_EFFECT = auto()
    FIREPLACE = auto()
    OKAY = auto()
    STOP_EFFECT = auto()
    STOP_HUE_EFFECT = auto()


@register_type_converter_fn(error_message="'{value}' is not a valid Effect")
def str_to_effect(value: str) -> Effect:
    """Convert string to Effect enum.

    Types are inferred from the function signature.
    """
    return Effect(value.lower())


# Now you can use it in handlers


class LightEffectApp(App):
    async def on_light_effect_change(self, effect: Annotated[Effect, A.get_attr_new("effect")]):
        self.logger.info("Light effect: %r", effect)

The decorator accepts keyword arguments for error handling:

Parameter Type Default Description
error_message str \| None None Message on conversion failure. Supports {value}, {from_type}, {to_type} placeholders.
error_types tuple[type[BaseException], ...] (ValueError,) Exceptions that trigger a wrapped UnableToConvertValueError. Other exceptions propagate as RuntimeError.

Simple Registration

register_simple_type_converter registers an existing callable (a constructor, a method, or a lambda) without wrapping it in a dedicated function.

from hassette import register_simple_type_converter

# Register a simple converter (uses int() as the converter function)
register_simple_type_converter(
    from_type=str,
    to_type=int,
    fn=int,  # Optional - defaults to to_type constructor if not provided
    error_message="Cannot convert '{value}' to integer",  # Optional
)

When fn is omitted, the target type's constructor is used. error_message and error_types accept the same arguments as the decorator form.

Common Patterns

Enum Conversion

from enum import Enum

from hassette import register_type_converter_fn


class FanSpeed(Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"


@register_type_converter_fn
def str_to_fan_speed(value: str) -> FanSpeed:
    """Convert string to FanSpeed enum.

    Types inferred from signature: str → FanSpeed
    """
    return FanSpeed(value.lower())

The decorator infers str → FanSpeed from the function signature. The converter is available at module import time.

Structured Data

import json
from dataclasses import dataclass

from hassette import register_type_converter_fn


@dataclass
class DeviceInfo:
    name: str
    version: str
    manufacturer: str


@register_type_converter_fn
def str_to_device_info(value: str) -> DeviceInfo:
    """Parse device info JSON.

    Types inferred from signature: str → DeviceInfo
    """
    data = json.loads(value)
    return DeviceInfo(**data)

json.loads raises json.JSONDecodeError (a ValueError subclass), so the default error_types=(ValueError,) catches parse failures automatically.

Error Handling

State Conversion Errors

try_convert_state raises specific exceptions for distinct failure modes.

InvalidDataForStateConversionError

Raised when the state data is malformed or missing required fields. For example, the input is None or contains an event key instead of a state dict.

from hassette import STATE_REGISTRY
from hassette.exceptions import InvalidDataForStateConversionError

try:
    state = STATE_REGISTRY.try_convert_state(None)
except InvalidDataForStateConversionError as e:
    print(f"Invalid state data: {e}")

InvalidEntityIdError

Raised when entity_id is missing, not a string, or lacks a . separator between domain and entity name.

from hassette import STATE_REGISTRY
from hassette.exceptions import InvalidEntityIdError

try:
    # Entity ID must have format "domain.entity"
    state = STATE_REGISTRY.try_convert_state({"entity_id": "invalid"})
except InvalidEntityIdError as e:
    print(f"Invalid entity ID: {e}")

UnableToConvertStateError

Raised when Pydantic validation fails for both the resolved state class and the BaseState fallback.

from hassette import STATE_REGISTRY
from hassette.exceptions import UnableToConvertStateError

data = {"entity_id": "light.bedroom", "state": "on"}  # Simplified data
try:
    state = STATE_REGISTRY.try_convert_state(data)
except UnableToConvertStateError as e:
    print(f"Conversion failed: {e}")
    # This exception means both the resolved class and the BaseState fallback failed

Value Conversion Errors

UnableToConvertValueError

When a registered converter raises one of its error_types, the registry wraps it in UnableToConvertValueError:

from hassette import TYPE_REGISTRY
from hassette.exceptions import UnableToConvertValueError

try:
    result = TYPE_REGISTRY.convert("not_a_number", int)
except UnableToConvertValueError as e:
    print(e)  # Error details about the conversion failure

When no converter is registered and the target type's constructor also fails:

from hassette import TYPE_REGISTRY
from hassette.exceptions import UnableToConvertValueError


class CustomType:
    def __init__(self, value):
        # This constructor raises to simulate a type that cannot be built from str
        raise TypeError("CustomType cannot be constructed from a string")


try:
    result = TYPE_REGISTRY.convert("value", CustomType)
except UnableToConvertValueError as e:
    print(e)  # "Unable to convert 'value' to <class 'CustomType'>"

Custom error messages with {value} make failures easier to diagnose:

from hassette import register_type_converter_fn


class MyType:
    pass


@register_type_converter_fn(error_message="Cannot convert '{value}' to MyType. Expected format: X,Y,Z")
def str_to_mytype(_: str) -> MyType:
    """Convert string to MyType with clear error handling.

    Types inferred from signature: str → MyType
    """
    # ... conversion logic with helpful ValueError messages
    return MyType()

Inspection and Debugging

TYPE_REGISTRY and STATE_REGISTRY are both available as top-level imports.

List all registered value converters:

from hassette import TYPE_REGISTRY

# Get all registered conversions
conversions = TYPE_REGISTRY.list_conversions()

for from_type, to_type, _entry in conversions:
    print(f"{from_type.__name__}{to_type.__name__}")

Output:

str → int
str → float
str → bool
int → float
...

Check whether a specific converter is registered:

from hassette import TYPE_REGISTRY

# Check if a converter exists
key = (str, int)
if key in TYPE_REGISTRY.conversion_map:
    entry = TYPE_REGISTRY.conversion_map[key]
    print(f"Converter found for {str} -> {int}")
else:
    print("No converter registered")

TypeRegistry.conversion_map is a dict keyed by (from_type, to_type) tuples. Each value is a TypeConverterEntry with func, from_type, to_type, error_types, and error_message fields.

Unexpected state type at runtime?

STATE_REGISTRY.resolve(domain="the_domain") confirms which class is registered. If a custom class override does not take effect, import order is the likely cause. The override class must be imported after the module that defines the original.

See Also