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:
-
StateRegistry.resolve(domain="binary_sensor")looks up the registered class for the domain. It returnsBinarySensorState. -
Pydantic validation begins on
BinarySensorState. -
The
_validate_domain_and_statemodel validator readsvalue_typefrom the class and delegates toTypeRegistry. -
TypeRegistrylooks up the(str, bool)converter and converts"on"toTrue. -
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 str → bool converter maps Home Assistant string values:
True:"on","true","yes","1"False:"off","false","no","0"
The bool → str 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
- Custom States: defining state classes for custom integrations
- Dependency Injection: how
D.StateNew[T]uses the registries - States Overview: the
self.statescache that sits above the registries