synthetic_home.synthetic_home

Data model for home assistant synthetic home.

  1"""Data model for home assistant synthetic home."""
  2
  3from dataclasses import dataclass, field
  4import pathlib
  5import logging
  6import slugify
  7from typing import Any
  8
  9from mashumaro.codecs.yaml import yaml_decode
 10
 11from synthetic_home.exceptions import SyntheticHomeError
 12from synthetic_home.device_types import (
 13    DeviceTypeRegistry,
 14    DeviceStateStrategy,
 15    DeviceState,
 16    EntityEntry,
 17    merge_entity_state_attributes,
 18)
 19from . import common, inventory
 20from .inventory import DEFAULT_SEPARATOR
 21from .device_types import load_device_type_registry
 22
 23__all__ = [
 24    "SyntheticHome",
 25    "Device",
 26    "build_device_state",
 27    "load_synthetic_home",
 28    "read_config_content",
 29]
 30
 31
 32_LOGGER = logging.getLogger(__name__)
 33
 34
 35@dataclass
 36class Device:
 37    """A synthetic device."""
 38
 39    name: str
 40    """A human readable name for the device."""
 41
 42    device_type: str | None = None
 43    """The type of the device in the device registry that determines how it maps to entities."""
 44
 45    device_info: common.DeviceInfo | None = None
 46    """Device make and model information."""
 47
 48    device_state: str | dict | DeviceState | None = None
 49    """A list of pre-canned RestorableStateAttributes specified by the key.
 50
 51    These are used for restoring a device into a specific state supported by the
 52    device type. This is used to use a label rather than specifying low level
 53    entity details. This is an alternative to specifying low level attributes above.
 54
 55    Restorable attributes overwrite normal attributes since they can be reloaded
 56    at runtime.
 57    """
 58
 59    entity_entries: dict[str, list[EntityEntry]] = field(default_factory=dict)
 60    """The validated set of entity entries"""
 61
 62    def merge(
 63        self,
 64        device_state: DeviceState | None = None,
 65        entity_entries: dict[str, list[EntityEntry]] | None = None,
 66    ) -> "Device":
 67        """Merge the existing device with a new device state."""
 68        return Device(
 69            name=self.name,
 70            device_type=self.device_type,
 71            device_info=self.device_info,
 72            device_state=device_state or self.device_state,
 73            entity_entries=entity_entries or self.entity_entries,
 74        )
 75
 76
 77def build_device_state(device: Device, registry: DeviceTypeRegistry) -> Device:
 78    """Validate the device and return a new instance."""
 79    if (device_type := registry.device_types.get(device.device_type or "")) is None:
 80        raise SyntheticHomeError(
 81            f"Device {device} has device_type {device.device_type} not found in: {registry.device_types}"
 82        )
 83
 84    if device.device_state is not None:
 85        # Lookup a device state and merge it into the current state
 86        if (
 87            isinstance(device.device_state, str)
 88            and device.device_state not in device_type.device_states_dict
 89        ):
 90            raise SyntheticHomeError(
 91                f"Device {device}\nhas state '{device.device_state}'\n not in: {device_type.device_states_dict}"
 92            )
 93        if isinstance(device.device_state, dict):
 94            _LOGGER.debug(
 95                "Parsing device state from dictionary: %s", device.device_state
 96            )
 97            strategy = DeviceStateStrategy()
 98            device = device.merge(
 99                device_state=strategy.deserialize(("custom", device.device_state))
100            )
101            _LOGGER.debug("Parsed=%s", device)
102
103    device_state: DeviceState | None = None
104    _LOGGER.debug("Checking device state: %s", device.device_state)
105    if (
106        device.device_state is None or isinstance(device.device_state, DeviceState)
107    ) and device_type.device_states:
108        # Pick the first device state as the default
109        device_state = device_type.device_states[0]
110        if isinstance(device.device_state, DeviceState):
111            device_state = device_state.merge(device.device_state)
112    elif device.device_state is not None and isinstance(device.device_state, str):
113        device_state = device_type.device_states_dict[device.device_state]
114    else:
115        raise SyntheticHomeError(f"Device did not declare a device state: {device}")
116
117    _LOGGER.debug("Merging entity attributes for device state: %s", device_state)
118    entity_entries = {
119        platform: [
120            merge_entity_state_attributes(
121                platform, entity_entry, device_state.entity_states
122            )
123            for entity_entry in entity_entries
124        ]
125        for platform, entity_entries in device_type.entities.items()
126    }
127    return device.merge(device_state=device_state, entity_entries=entity_entries)
128
129
130@dataclass
131class SyntheticHome:
132    """Data about a synthetic home."""
133
134    name: str
135    """A human readable name for the home."""
136
137    # Devices by area
138    devices: dict[str, list[Device]] = field(default_factory=dict)
139
140    # Services for the home not for a specific area
141    services: list[Device] = field(default_factory=list)
142
143    # Device types supported by the home.
144    device_type_registry: DeviceTypeRegistry | None = None
145
146    def __post_init__(self) -> None:
147        """Build the complete device state."""
148        if self.device_type_registry is None:
149            self.device_type_registry = load_device_type_registry()
150        self.devices = {
151            key: [
152                build_device_state(device, self.device_type_registry)
153                for device in devices
154            ]
155            for key, devices in self.devices.items()
156        }
157        self.services = [
158            build_device_state(device, self.device_type_registry)
159            for device in self.services
160        ]
161
162
163def read_config_content(config_file: pathlib.Path) -> str:
164    """Create configuration file content, exposed for patching."""
165    with config_file.open("r") as f:
166        return f.read()
167
168
169def load_synthetic_home(config_file: pathlib.Path) -> SyntheticHome:
170    """Load synthetic home configuration from disk."""
171    try:
172        content = read_config_content(config_file)
173    except FileNotFoundError:
174        raise SyntheticHomeError(f"Configuration file '{config_file}' does not exist")
175    try:
176        return yaml_decode(content, SyntheticHome)
177    except ValueError as err:
178        raise SyntheticHomeError(f"Could not parse config file '{config_file}': {err}")
179
180
181def yaml_state_value(v: Any) -> Any:
182    """Convert a entity state value to yaml."""
183    if isinstance(v, bool) or isinstance(v, float) or isinstance(v, list):
184        return v
185    return str(v)
186
187
188def build_entities(area_id: str | None, device_entry: Device) -> list[inventory.Entity]:
189    """Build the set of entities for the device entry."""
190    entities = []
191    device_name = device_entry.name.replace("_", " ").title()
192    device_id = slugify.slugify(device_entry.name, separator=DEFAULT_SEPARATOR)
193
194    for platform, entity_entries in device_entry.entity_entries.items():
195        for entity_entry in entity_entries:
196            # Each entity in this platform needs a unique name, but
197            # if the key is in the name it's the primary to avoid "Motion motion"
198            entity_name = device_name
199            if platform == "sensor" or (
200                platform == "binary_sensor"
201                and entity_entry.key not in device_entry.name.lower()
202            ):
203                entity_name = f"{device_name} {entity_entry.key.capitalize()}"
204            entity_id = f"{platform}.{slugify.slugify(entity_name, separator=DEFAULT_SEPARATOR)}"
205            entity = inventory.Entity(
206                name=entity_name,
207                id=entity_id,
208                device=device_id,
209            )
210            if area_id:
211                entity.area = area_id
212            attributes: dict[str, str | list[str] | int | float] = {}
213            if entity_entry.attributes:
214                attributes.update(entity_entry.attributes)
215            state = attributes.pop("state", None)
216            if state is not None:
217                entity.state = yaml_state_value(state)
218            if attributes:
219                entity.attributes = attributes
220            entities.append(entity)
221    return entities
222
223
224def build_inventory(home: SyntheticHome) -> inventory.Inventory:
225    """Build a home inventory from the synthetic home definition.
226
227    This is a flattened set of areas, entities, and devices.
228    """
229
230    inv = inventory.Inventory()
231    pairs: list[tuple[str | None, list[Device]]] = [*home.devices.items()]
232    if home.services:
233        pairs.append((None, home.services))
234
235    device_ids: set[str] = set()
236    entities = []
237    for area_name, devices in pairs:
238        if area_name:
239            area_id = slugify.slugify(area_name, separator=DEFAULT_SEPARATOR)
240            inv.areas.append(inventory.Area(name=area_name, id=area_id))
241        else:
242            area_id = None
243
244        for device_entry in devices:
245            # Make computer generated device names more friendly
246            device_name = device_entry.name.replace("_", " ").title()
247            device_id = slugify.slugify(device_entry.name, separator=DEFAULT_SEPARATOR)
248            if device_id in device_ids:
249                device_entry.name = f"{area_name}_{device_entry.name}"
250                device_name = device_entry.name.replace("_", " ").title()
251                device_id = slugify.slugify(device_name, separator=DEFAULT_SEPARATOR)
252            device_ids.add(device_id)
253            device = inventory.Device(
254                name=device_name,
255                id=device_id,
256                info=device_entry.device_info,
257            )
258            if area_id:
259                device.area = area_id
260            inv.devices.append(device)
261            entities.extend(build_entities(area_id, device_entry))
262    if entities:
263        inv.entities = entities
264    return inv
@dataclass
class SyntheticHome:
131@dataclass
132class SyntheticHome:
133    """Data about a synthetic home."""
134
135    name: str
136    """A human readable name for the home."""
137
138    # Devices by area
139    devices: dict[str, list[Device]] = field(default_factory=dict)
140
141    # Services for the home not for a specific area
142    services: list[Device] = field(default_factory=list)
143
144    # Device types supported by the home.
145    device_type_registry: DeviceTypeRegistry | None = None
146
147    def __post_init__(self) -> None:
148        """Build the complete device state."""
149        if self.device_type_registry is None:
150            self.device_type_registry = load_device_type_registry()
151        self.devices = {
152            key: [
153                build_device_state(device, self.device_type_registry)
154                for device in devices
155            ]
156            for key, devices in self.devices.items()
157        }
158        self.services = [
159            build_device_state(device, self.device_type_registry)
160            for device in self.services
161        ]

Data about a synthetic home.

SyntheticHome( name: str, devices: dict[str, list[Device]] = <factory>, services: list[Device] = <factory>, device_type_registry: synthetic_home.device_types.DeviceTypeRegistry | None = None)
name: str

A human readable name for the home.

devices: dict[str, list[Device]]
services: list[Device]
device_type_registry: synthetic_home.device_types.DeviceTypeRegistry | None = None
@dataclass
class Device:
36@dataclass
37class Device:
38    """A synthetic device."""
39
40    name: str
41    """A human readable name for the device."""
42
43    device_type: str | None = None
44    """The type of the device in the device registry that determines how it maps to entities."""
45
46    device_info: common.DeviceInfo | None = None
47    """Device make and model information."""
48
49    device_state: str | dict | DeviceState | None = None
50    """A list of pre-canned RestorableStateAttributes specified by the key.
51
52    These are used for restoring a device into a specific state supported by the
53    device type. This is used to use a label rather than specifying low level
54    entity details. This is an alternative to specifying low level attributes above.
55
56    Restorable attributes overwrite normal attributes since they can be reloaded
57    at runtime.
58    """
59
60    entity_entries: dict[str, list[EntityEntry]] = field(default_factory=dict)
61    """The validated set of entity entries"""
62
63    def merge(
64        self,
65        device_state: DeviceState | None = None,
66        entity_entries: dict[str, list[EntityEntry]] | None = None,
67    ) -> "Device":
68        """Merge the existing device with a new device state."""
69        return Device(
70            name=self.name,
71            device_type=self.device_type,
72            device_info=self.device_info,
73            device_state=device_state or self.device_state,
74            entity_entries=entity_entries or self.entity_entries,
75        )

A synthetic device.

Device( name: str, device_type: str | None = None, device_info: synthetic_home.common.DeviceInfo | None = None, device_state: str | dict | synthetic_home.device_types.DeviceState | None = None, entity_entries: dict[str, list[synthetic_home.device_types.EntityEntry]] = <factory>)
name: str

A human readable name for the device.

device_type: str | None = None

The type of the device in the device registry that determines how it maps to entities.

device_info: synthetic_home.common.DeviceInfo | None = None

Device make and model information.

device_state: str | dict | synthetic_home.device_types.DeviceState | None = None

A list of pre-canned RestorableStateAttributes specified by the key.

These are used for restoring a device into a specific state supported by the device type. This is used to use a label rather than specifying low level entity details. This is an alternative to specifying low level attributes above.

Restorable attributes overwrite normal attributes since they can be reloaded at runtime.

entity_entries: dict[str, list[synthetic_home.device_types.EntityEntry]]

The validated set of entity entries

def merge( self, device_state: synthetic_home.device_types.DeviceState | None = None, entity_entries: dict[str, list[synthetic_home.device_types.EntityEntry]] | None = None) -> Device:
63    def merge(
64        self,
65        device_state: DeviceState | None = None,
66        entity_entries: dict[str, list[EntityEntry]] | None = None,
67    ) -> "Device":
68        """Merge the existing device with a new device state."""
69        return Device(
70            name=self.name,
71            device_type=self.device_type,
72            device_info=self.device_info,
73            device_state=device_state or self.device_state,
74            entity_entries=entity_entries or self.entity_entries,
75        )

Merge the existing device with a new device state.

def build_device_state( device: Device, registry: synthetic_home.device_types.DeviceTypeRegistry) -> Device:
 78def build_device_state(device: Device, registry: DeviceTypeRegistry) -> Device:
 79    """Validate the device and return a new instance."""
 80    if (device_type := registry.device_types.get(device.device_type or "")) is None:
 81        raise SyntheticHomeError(
 82            f"Device {device} has device_type {device.device_type} not found in: {registry.device_types}"
 83        )
 84
 85    if device.device_state is not None:
 86        # Lookup a device state and merge it into the current state
 87        if (
 88            isinstance(device.device_state, str)
 89            and device.device_state not in device_type.device_states_dict
 90        ):
 91            raise SyntheticHomeError(
 92                f"Device {device}\nhas state '{device.device_state}'\n not in: {device_type.device_states_dict}"
 93            )
 94        if isinstance(device.device_state, dict):
 95            _LOGGER.debug(
 96                "Parsing device state from dictionary: %s", device.device_state
 97            )
 98            strategy = DeviceStateStrategy()
 99            device = device.merge(
100                device_state=strategy.deserialize(("custom", device.device_state))
101            )
102            _LOGGER.debug("Parsed=%s", device)
103
104    device_state: DeviceState | None = None
105    _LOGGER.debug("Checking device state: %s", device.device_state)
106    if (
107        device.device_state is None or isinstance(device.device_state, DeviceState)
108    ) and device_type.device_states:
109        # Pick the first device state as the default
110        device_state = device_type.device_states[0]
111        if isinstance(device.device_state, DeviceState):
112            device_state = device_state.merge(device.device_state)
113    elif device.device_state is not None and isinstance(device.device_state, str):
114        device_state = device_type.device_states_dict[device.device_state]
115    else:
116        raise SyntheticHomeError(f"Device did not declare a device state: {device}")
117
118    _LOGGER.debug("Merging entity attributes for device state: %s", device_state)
119    entity_entries = {
120        platform: [
121            merge_entity_state_attributes(
122                platform, entity_entry, device_state.entity_states
123            )
124            for entity_entry in entity_entries
125        ]
126        for platform, entity_entries in device_type.entities.items()
127    }
128    return device.merge(device_state=device_state, entity_entries=entity_entries)

Validate the device and return a new instance.

def load_synthetic_home( config_file: pathlib._local.Path) -> SyntheticHome:
170def load_synthetic_home(config_file: pathlib.Path) -> SyntheticHome:
171    """Load synthetic home configuration from disk."""
172    try:
173        content = read_config_content(config_file)
174    except FileNotFoundError:
175        raise SyntheticHomeError(f"Configuration file '{config_file}' does not exist")
176    try:
177        return yaml_decode(content, SyntheticHome)
178    except ValueError as err:
179        raise SyntheticHomeError(f"Could not parse config file '{config_file}': {err}")

Load synthetic home configuration from disk.

def read_config_content(config_file: pathlib._local.Path) -> str:
164def read_config_content(config_file: pathlib.Path) -> str:
165    """Create configuration file content, exposed for patching."""
166    with config_file.open("r") as f:
167        return f.read()

Create configuration file content, exposed for patching.