Skip to content

Commit

Permalink
Merge pull request #8 from geappliances/meta-erd-json
Browse files Browse the repository at this point in the history
Meta erd json
  • Loading branch information
ben-strehl authored Feb 6, 2025
2 parents b042b4a + b428718 commit 3b73707
Show file tree
Hide file tree
Showing 27 changed files with 3,233 additions and 170 deletions.
62 changes: 43 additions & 19 deletions custom_components/geappliances/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
import logging

import aiofiles
Expand Down Expand Up @@ -38,35 +39,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data.pop(DOMAIN)
return ok
ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not ok:
return False

hass.data[DOMAIN]["unsubscribe"]()
hass.data.pop(DOMAIN)

return True


async def get_appliance_api_json() -> str:
"""Read the appliance API JSON file and return its contents."""
async with aiofiles.open(
"custom_components/geappliances/appliance_api/appliance_api_erd_definitions.json",
encoding="utf-8",
) as appliance_api_erd_definitions:
return await appliance_api_erd_definitions.read()

return False

async def get_appliance_api_erd_defs_json() -> str:
"""Read the appliance API ERD definitions JSON file and return its contents."""
async with aiofiles.open(
"custom_components/geappliances/appliance_api/appliance_api_erd_definitions.json",
encoding="utf-8",
) as appliance_api_erd_definitions:
return await appliance_api_erd_definitions.read()


async def get_meta_erds_json() -> str:
"""Read the meta ERD JSON file and return its contents."""
async with aiofiles.open(
"custom_components/geappliances/meta_erds.json",
) as meta_erd_json_file:
return await meta_erd_json_file.read()


async def start_discovery(hass: HomeAssistant, entry: ConfigEntry) -> GeaDiscovery:
"""Create the discovery singleton asynchronously."""

mqtt_client = GeaMQTTClient(hass)

async with aiofiles.open(
"custom_components/geappliances/appliance_api/appliance_api.json",
encoding="utf-8",
) as appliance_api:
contents_api = await appliance_api.read()

async with aiofiles.open(
"custom_components/geappliances/appliance_api/appliance_api_erd_definitions.json",
encoding="utf-8",
) as appliance_api_erd_definitions:
contents_api_erd_defintions = await appliance_api_erd_definitions.read()
data_source = DataSource(
contents_api, contents_api_erd_defintions, mqtt_client
)
data_source = DataSource(
await get_appliance_api_json(),
await get_appliance_api_erd_defs_json(),
mqtt_client,
)

meta_erd_coordinator = MetaErdCoordinator(
data_source, json.loads(await get_meta_erds_json()), hass
)
registry_updater = RegistryUpdater(hass, entry)
meta_erd_coordinator = MetaErdCoordinator(data_source, hass)

gea_discovery = GeaDiscovery(registry_updater, data_source, meta_erd_coordinator)

await mqtt.client.async_subscribe(
Expand Down
3 changes: 2 additions & 1 deletion custom_components/geappliances/config_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def __init__(self, data_source: DataSource) -> None:
r"(oz)": "fl. oz.",
r"(mL)": "mL",
r"(L)": "L",
r" lbs|(lbs)": "lbs",
r" lbs|(lbs)": "lb",
r"mA$| mA |(mA)": "mA",
r"seconds": "s",
r"minutes": "min",
Expand Down Expand Up @@ -264,6 +264,7 @@ async def build_text(
base.erd,
base.offset,
base.size,
field["type"] == "raw",
)

async def build_time(
Expand Down
6 changes: 6 additions & 0 deletions custom_components/geappliances/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
Platform.TIME,
]

COMMON_APPLIANCE_API_ERD = 0x0092
FEATURE_API_ERD_LOW_START = 0x0093
FEATURE_API_ERD_LOW_END = 0x0097
FEATURE_API_ERD_HIGH_START = 0x0109
FEATURE_API_ERD_HIGH_END = 0x010D

DOMAIN = "geappliances"
GEA_ENTITY_NEW = "gea_entity_new_{}"
DISCOVERY = "discovery"
Expand Down
21 changes: 18 additions & 3 deletions custom_components/geappliances/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@

import logging

from .const import Erd
from .const import (
COMMON_APPLIANCE_API_ERD,
FEATURE_API_ERD_HIGH_END,
FEATURE_API_ERD_HIGH_START,
FEATURE_API_ERD_LOW_END,
FEATURE_API_ERD_LOW_START,
Erd,
)
from .erd_factory import ERDFactory
from .ha_compatibility.data_source import DataSource
from .ha_compatibility.meta_erds import MetaErdCoordinator
Expand Down Expand Up @@ -44,10 +51,18 @@ async def handle_message(self, msg: MQTTMessage) -> None:
if len(split_topic) == 5 and split_topic[4] == "value":
erd: Erd = int(split_topic[3], base=16)
if not await self._data_source.erd_is_supported_by_device(device_name, erd):
if erd == 0x0092:
if erd == COMMON_APPLIANCE_API_ERD:
await self._data_source.add_unsupported_erd_to_device(
device_name, erd, msg.payload
)
await self.process_common_appliance_api(msg, device_name)

elif (0x0093 <= erd <= 0x0097) or (0x0109 <= erd <= 0x010D):
elif (FEATURE_API_ERD_LOW_START <= erd <= FEATURE_API_ERD_LOW_END) or (
FEATURE_API_ERD_HIGH_START <= erd <= FEATURE_API_ERD_HIGH_END
):
await self._data_source.add_unsupported_erd_to_device(
device_name, erd, msg.payload
)
await self.process_feature_appliance_api(msg, device_name)

else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,8 @@ async def _get_erd_from_either_list(

return self._data[device_name][UNSUPPORTED_ERDS][erd]

async def erd_read(self, device_name: str, erd: Erd) -> bytes | None:
"""Return the value of the specified ERD."""
async def erd_read(self, device_name: str, erd: Erd) -> bytes:
"""Return the value of the specified ERD. Raises if the ERD is not present on the given device."""
return (await self._get_erd_from_either_list(device_name, erd))[VALUE]

async def erd_write(self, device_name: str, erd: Erd, value: bytes) -> None:
Expand Down
171 changes: 109 additions & 62 deletions custom_components/geappliances/ha_compatibility/meta_erds.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Module to manage meta ERDs."""

import logging
from typing import Any
from typing import TYPE_CHECKING, Any

from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
Expand All @@ -14,7 +14,12 @@
ATTR_MIN_VAL,
ATTR_UNIQUE_ID,
ATTR_UNIT,
COMMON_APPLIANCE_API_ERD,
DOMAIN,
FEATURE_API_ERD_HIGH_END,
FEATURE_API_ERD_HIGH_START,
FEATURE_API_ERD_LOW_END,
FEATURE_API_ERD_LOW_START,
SERVICE_ENABLE_OR_DISABLE,
SERVICE_SET_ALLOWABLES,
SERVICE_SET_MAX,
Expand Down Expand Up @@ -127,31 +132,120 @@ async def set_allowables(
class MetaErdCoordinator:
"""Class to manage meta ERDs and apply transforms."""

def __init__(self, data_source: DataSource, hass: HomeAssistant) -> None:
def __init__(
self,
data_source: DataSource,
meta_erd_json: dict[Any, Any],
hass: HomeAssistant,
) -> None:
"""Create the meta ERD coordinator."""
self._entity_registry = er.async_get(hass)
self._hass = hass
self._data_source = data_source
self._transform_table = _TRANSFORM_TABLE
self._transform_table = meta_erd_json
self._create_entities_to_meta_erds_dict()

def _create_entities_to_meta_erds_dict(self) -> None:
self._entities_to_meta_erds = {
entity_id: meta_erd
for meta_erd, row_dict in self._transform_table.items()
for transform_row in row_dict.values()
for entity_id in transform_row["fields"]
}
self._entities_to_meta_erds: dict[str, list[Erd]] = {}
for feature_type in self._transform_table:
for feature_version in self._transform_table[feature_type]:
for meta_erd, row_dict in self._transform_table[feature_type][
feature_version
].items():
for transform_row in row_dict.values():
for entity_id in transform_row["fields"]:
if self._entities_to_meta_erds.get(entity_id) is None:
self._entities_to_meta_erds[entity_id] = [meta_erd]
elif meta_erd not in self._entities_to_meta_erds[entity_id]:
self._entities_to_meta_erds[entity_id].append(meta_erd)

async def is_meta_erd(self, erd: Erd) -> bool:
"""Return true if the given ERD is a meta ERD."""
return erd in self._transform_table
for feature_type in self._transform_table:
for feature_version in self._transform_table[feature_type]:
if erd in self._transform_table[feature_type][feature_version]:
return True

return False

async def _look_for_erd_def_in_appliance_api(
self, device_name: str, api_erd: Erd, meta_erd: Erd
) -> tuple[str, str] | None:
"""Check the given appliance API ERD and return a tuple containing the feature type and version if it contains the given meta ERD."""
try:
api_value = await self._data_source.erd_read(device_name, api_erd)
except KeyError:
return None
else:
if api_erd == 0x0092:
feature_type = "common"
feature_version = f"{int.from_bytes(api_value[0:4])}"
feature_mask = int.from_bytes(api_value[4:8])
feature_def = await self._data_source.get_common_appliance_api_version(
feature_version
)
else:
feature_type = f"{int.from_bytes(api_value[0:2])}"
feature_version = f"{int.from_bytes(api_value[2:4])}"
feature_mask = int.from_bytes(api_value[4:8])
feature_def = await self._data_source.get_feature_api_version(
feature_type, feature_version
)

if TYPE_CHECKING:
assert feature_def is not None

for erd_def in feature_def["required"]:
if erd_def["erd"] == f"{meta_erd:#06x}":
return (feature_type, feature_version)

for feature in feature_def["features"]:
if int(feature["mask"], base=16) & feature_mask:
for erd_def in feature["required"]:
if erd_def["erd"] == f"{meta_erd:#06x}":
return (feature_type, feature_version)

return None

async def _get_meta_erd_feature_type_and_version(
self, device_name: str, meta_erd: Erd
) -> tuple[str, str] | None:
"""Return a tuple containing the feature type and version associated with the meta ERD on this device."""
feature_type_and_version = await self._look_for_erd_def_in_appliance_api(
device_name, COMMON_APPLIANCE_API_ERD, meta_erd
)
if feature_type_and_version is not None:
return feature_type_and_version

for api_erd in range(FEATURE_API_ERD_LOW_START, FEATURE_API_ERD_LOW_END):
feature_type_and_version = await self._look_for_erd_def_in_appliance_api(
device_name, api_erd, meta_erd
)
if feature_type_and_version is not None:
return feature_type_and_version

for api_erd in range(FEATURE_API_ERD_HIGH_START, FEATURE_API_ERD_HIGH_END):
feature_type_and_version = await self._look_for_erd_def_in_appliance_api(
device_name, api_erd, meta_erd
)
if feature_type_and_version is not None:
return feature_type_and_version

return None

async def apply_transforms_for_meta_erd(
self, device_name: str, meta_erd: Erd
) -> None:
"""Apply transforms for the given meta ERD. Will raise KeyError if meta_erd is not a meta ERD."""
for meta_field, transform_row in self._transform_table[meta_erd].items():
feature_type_and_version = await self._get_meta_erd_feature_type_and_version(
device_name, meta_erd
)
if feature_type_and_version is None:
return

for meta_field, transform_row in self._transform_table[
feature_type_and_version[0]
][feature_type_and_version[1]][meta_erd].items():
field_bytes = await self.get_bytes_for_field(
device_name, meta_erd, meta_field
)
Expand All @@ -177,10 +271,11 @@ async def apply_transforms_to_entity(
self, device_name: str, entity_id: str
) -> None:
"""Check if any meta ERDs have transforms for the given entity and apply them."""
meta_erd = self._entities_to_meta_erds.get(entity_id)
meta_erds = self._entities_to_meta_erds.get(entity_id)

if meta_erd is not None:
await self.apply_transforms_for_meta_erd(device_name, meta_erd)
if meta_erds is not None:
for meta_erd in meta_erds:
await self.apply_transforms_for_meta_erd(device_name, meta_erd)

async def get_bytes_for_field(
self, device_name: str, erd: Erd, field: str
Expand Down Expand Up @@ -226,51 +321,3 @@ async def _get_bits_from_bytes(self, field_def: dict, field_bytes: bytes) -> byt
masked = int.from_bytes(field_bytes) & mask

return masked.to_bytes()


_TRANSFORM_TABLE: dict[Erd, dict[str, dict[str, Any]]] = {
0x0007: {
"Temperature Display Units": {
"fields": ["number.target_cooling_temperature"],
"func": set_unit,
}
},
0x4040: {
"Available Modes.Hybrid": {
"fields": ["select.mode.Hybrid"],
"func": set_allowables,
},
"Available Modes.Standard electric": {
"fields": ["select.mode.Standard electric"],
"func": set_allowables,
},
"Available Modes.E-heat": {
"fields": ["select.mode.E-heat"],
"func": set_allowables,
},
"Available Modes.HiDemand": {
"fields": ["select.mode.HiDemand"],
"func": set_allowables,
},
"Available Modes.Vacation": {
"fields": ["select.mode.Vacation"],
"func": set_allowables,
},
},
0x4047: {
"Minimum setpoint": {
"fields": ["{}_4024_Temperature"],
"func": set_min,
},
"Maximum setpoint": {
"fields": ["number.temperature"],
"func": set_max,
},
},
0x214E: {
"Eco Option Is In Client Writable State": {
"fields": ["select.eco_option_status"],
"func": enable_or_disable,
},
},
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ async def add_entity_to_device(
) -> None:
"""Create an entity from the config and add it to the device."""
_LOGGER.debug("Adding %s to %s", config.platform, device_name)
# Fire the correct event based on the type of entity: binary sensor, switch, etc.
async_dispatcher_send(
self._hass, GEA_ENTITY_NEW.format(config.platform), config
)
if "reserved" not in config.name and "Reserved" not in config.name:
async_dispatcher_send(
self._hass, GEA_ENTITY_NEW.format(config.platform), config
)

async def create_device(self, device_name: str) -> str:
"""Create a device and add it to the registry."""
Expand Down
Loading

0 comments on commit 3b73707

Please sign in to comment.