Skip to content

Commit

Permalink
Bump ta-cmi to 3.0.0 (#41)
Browse files Browse the repository at this point in the history
* Bump ta-cmi to 3.0.0

* Add CAN-IDs to config flow

* Add CAN-IDs to options flow

* Add config flow tests

* Update sensor handling

* Add repair flow

* Update README
  • Loading branch information
DeerMaximum authored Nov 23, 2023
1 parent 5a70512 commit 74f6514
Show file tree
Hide file tree
Showing 18 changed files with 395 additions and 124 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ To apply changes from the CoE configuration, reload the integration.

## Configuration

The integration automatically detects if the [Coe to HTTP add-on][CoEHttpAddon] is installed in the Home Assistant instance, in which case no further configuration is necessary.
The integration automatically detects if the [Coe to HTTP add-on][CoEHttpAddon] is installed in the Home Assistant instance, in which case only providing a target CAN-ID is necessary.

If an external server is used, the IP and PORT of this server must be specified during setup.

Expand Down
48 changes: 31 additions & 17 deletions custom_components/ta_coe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@

from .const import (
_LOGGER,
CONF_CAN_IDS,
CONF_ENTITIES_TO_SEND,
CONF_SCAN_INTERVAL,
DOMAIN,
SCAN_INTERVAL,
TYPE_BINARY,
TYPE_SENSOR,
)
from .issues import check_coe_server_2x_issue
from .refresh_task import RefreshTask
from .state_observer import StateObserver
from .state_sender import StateSender
Expand All @@ -30,6 +32,9 @@

async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up platform from a ConfigEntry."""

check_coe_server_2x_issue(hass, entry)

host: str = entry.data[CONF_HOST]

update_interval: timedelta = SCAN_INTERVAL
Expand All @@ -39,7 +44,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

coe = CoE(host, async_get_clientsession(hass))

coordinator = CoEDataUpdateCoordinator(hass, entry, coe, update_interval)
can_ids: list[int] = entry.data.get(CONF_CAN_IDS, [])

coordinator = CoEDataUpdateCoordinator(hass, entry, coe, can_ids, update_interval)

entry.async_on_unload(entry.add_update_listener(_async_update_listener))

Expand Down Expand Up @@ -89,19 +96,21 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
class CoEDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching CoE data."""

channel_count = 0
channel_count: dict[int, int] = {}

def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
coe: CoE,
can_ids: list[int],
update_interval: timedelta,
) -> None:
"""Initialize."""
self.config_entry = config_entry

self.coe = coe
self.can_ids = can_ids

_LOGGER.debug("Used update interval: %s", update_interval)

Expand Down Expand Up @@ -137,36 +146,41 @@ def _get_type(mode: ChannelMode) -> str:

return TYPE_BINARY

async def _check_new_channel(self, new_data: dict[str, Any]) -> None:
async def _check_new_channel(self, can_id: int, new_data: dict[str, Any]) -> None:
"""Check and reload if a new channel exists."""
new_channel_count = len(new_data[TYPE_BINARY]) + len(new_data[TYPE_SENSOR])

if self.channel_count != new_channel_count and self.channel_count != 0:
if (
self.channel_count.get(can_id, 0) != new_channel_count
and self.channel_count.get(can_id, 0) != 0
):
_LOGGER.debug("New channels detected. Reload integration.")
await self.hass.async_add_job(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)

self.channel_count = new_channel_count
self.channel_count[can_id] = new_channel_count

async def _async_update_data(self) -> dict[str, Any]:
async def _async_update_data(self) -> dict[int, Any]:
"""Update data."""
try:
return_data: dict[str, Any] = {TYPE_BINARY: {}, TYPE_SENSOR: {}}

return_data: dict[int, dict[str, Any]] = {}
_LOGGER.debug("Try to update CoE")

await self.coe.update()
for can_id in self.can_ids:
return_data[can_id] = {TYPE_BINARY: {}, TYPE_SENSOR: {}}

await self.coe.update(can_id)

for mode in ChannelMode:
for index, channel in self.coe.get_channels(mode).items():
value, unit = self._format_input(channel)
return_data[self._get_type(mode)][index] = {
"value": value,
"unit": unit,
}
for mode in ChannelMode:
for index, channel in self.coe.get_channels(can_id, mode).items():
value, unit = self._format_input(channel)
return_data[can_id][self._get_type(mode)][index] = {
"value": value,
"unit": unit,
}

await self._check_new_channel(return_data)
await self._check_new_channel(can_id, return_data[can_id])

return return_data
except ApiError as err:
Expand Down
22 changes: 15 additions & 7 deletions custom_components/ta_coe/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ async def async_setup_entry(

entities: list[DeviceChannelBinary | CoESendState] = []

for index, _ in coordinator.data[TYPE_BINARY].items():
channel: DeviceChannelBinary = DeviceChannelBinary(coordinator, index)
entities.append(channel)
for can_id in coordinator.data.keys():
for index, _ in coordinator.data[can_id][TYPE_BINARY].items():
channel: DeviceChannelBinary = DeviceChannelBinary(
coordinator, can_id, index
)
entities.append(channel)

entities.append(
CoESendState(coordinator.config_entry.data.get(CONF_ENTITIES_TO_SEND, {}))
Expand All @@ -47,19 +50,24 @@ async def async_setup_entry(
class DeviceChannelBinary(CoordinatorEntity, BinarySensorEntity):
"""Representation of an CoE channel."""

def __init__(self, coordinator: CoEDataUpdateCoordinator, channel_id: int) -> None:
def __init__(
self, coordinator: CoEDataUpdateCoordinator, can_id: int, channel_id: int
) -> None:
"""Initialize."""
super().__init__(coordinator)
self._id = channel_id
self._can_id = can_id
self._coordinator = coordinator

self._attr_name: str = f"CoE Digital - {self._id}"
self._attr_unique_id: str = f"ta-coe-digital-{self._id}"
self._attr_name: str = f"CoE Digital - CAN{self._can_id} {self._id}"
self._attr_unique_id: str = f"ta-coe-digital-can{self._can_id}-{self._id}"

@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
channel_raw: dict[str, Any] = self._coordinator.data[TYPE_BINARY][self._id]
channel_raw: dict[str, Any] = self._coordinator.data[self._can_id][TYPE_BINARY][
self._id
]

value: str = channel_raw["value"]

Expand Down
57 changes: 52 additions & 5 deletions custom_components/ta_coe/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
ADDON_DEFAULT_PORT,
ADDON_HOSTNAME,
ALLOWED_DOMAINS,
CONF_CAN_IDS,
CONF_ENTITIES_TO_SEND,
CONF_SCAN_INTERVAL,
CONF_SLOT_COUNT,
Expand All @@ -33,6 +34,25 @@ def validate_entity(hass: HomeAssistant, entity_id: str) -> bool:
return entity_id.startswith(ALLOWED_DOMAINS) and hass.states.get(entity_id)


def split_can_ids(raw_ids: str) -> list[int]:
"""Split string to can ids."""
string_ids = raw_ids.split(",")
can_ids: list[int] = []

for id_str in string_ids:
if not id_str.strip().isdigit():
raise CANIDError(id_str)

parsed_id = int(id_str)

if parsed_id <= 0 or parsed_id > 64:
raise CANIDError(id_str)

can_ids.append(parsed_id)

return can_ids


class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Technische Alternative CoE."""

Expand All @@ -51,7 +71,7 @@ async def check_addon_available(self) -> bool:
)
try:
async with timeout(10):
await coe.update()
await coe.update(1)
except (ApiError, asyncio.TimeoutError):
return False
else:
Expand All @@ -69,20 +89,23 @@ async def async_step_user(
and not self._async_current_entries()
):
self._config = {CONF_HOST: f"http://{ADDON_HOSTNAME}:{ADDON_DEFAULT_PORT}"}
return await self.async_step_menu()

if user_input is not None:
if not user_input[CONF_HOST].startswith("http://"):
user_input[CONF_HOST] = "http://" + user_input[CONF_HOST]

try:
user_input[CONF_CAN_IDS] = split_can_ids(user_input[CONF_CAN_IDS])

coe: CoE = CoE(
user_input[CONF_HOST], async_get_clientsession(self.hass)
)
async with timeout(10):
await coe.update()
await coe.get_server_version()
except (ApiError, asyncio.TimeoutError):
errors["base"] = "cannot_connect"
except CANIDError:
errors["base"] = "invalid_can_id"
except Exception as err: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception: %s", err)
errors["base"] = "unknown"
Expand All @@ -92,7 +115,14 @@ async def async_step_user(

return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_HOST): cv.string}),
data_schema=vol.Schema(
{
vol.Required(
CONF_HOST, default=self._config.get(CONF_HOST, "")
): cv.string,
vol.Required(CONF_CAN_IDS): cv.string,
}
),
errors=errors,
)

Expand Down Expand Up @@ -162,11 +192,14 @@ def get_schema(config: dict[str, Any]) -> vol.Schema:
if config.get(CONF_SCAN_INTERVAL, None) is not None:
default_interval = timedelta(minutes=config.get(CONF_SCAN_INTERVAL))

default_can_ids = ",".join(str(i) for i in config.get(CONF_CAN_IDS, []))

return vol.Schema(
{
vol.Required(
CONF_SCAN_INTERVAL, default=default_interval.seconds / 60
): vol.All(cv.positive_float, vol.Range(min=0.1, max=60.0)),
vol.Required(CONF_CAN_IDS, default=default_can_ids): cv.string,
}
)

Expand Down Expand Up @@ -266,10 +299,24 @@ async def async_step_general(
if user_input is not None and not errors:
self.data[CONF_SCAN_INTERVAL] = user_input[CONF_SCAN_INTERVAL]

return self.async_create_entry(title="", data=self.data)
try:
self.data[CONF_CAN_IDS] = split_can_ids(user_input[CONF_CAN_IDS])
except CANIDError:
errors["base"] = "invalid_can_id"
else:
return self.async_create_entry(title="", data=self.data)

return self.async_show_form(
step_id="general",
data_schema=get_schema(self.data),
errors=errors,
)


class CANIDError(Exception):
"""Raised when invalid CAN-ID detected."""

def __init__(self, status: str) -> None:
"""Initialize."""
super().__init__(status)
self.status = status
1 change: 1 addition & 0 deletions custom_components/ta_coe/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ADDON_HOSTNAME = "a824d5a9-ta-coe"
ADDON_DEFAULT_PORT = 9000

CONF_CAN_IDS = "can_ids"
CONF_SCAN_INTERVAL = "scan_interval"
CONF_ENTITIES_TO_SEND = "entities_to_send"
CONF_SLOT_COUNT = "slot_count"
Expand Down
23 changes: 23 additions & 0 deletions custom_components/ta_coe/issues.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Issues for Technische Alternative CoE integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir

from custom_components.ta_coe import CONF_CAN_IDS

from .const import DOMAIN


def check_coe_server_2x_issue(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Check and create issues related to the CoE server 2.x upgrade."""

if entry.data.get(CONF_CAN_IDS, None) is None:
ir.async_create_issue(
hass,
DOMAIN,
"add_missing_can_id",
data=entry.entry_id,
is_fixable=True,
severity=ir.IssueSeverity.ERROR,
translation_key="add_missing_can_id",
)
2 changes: 1 addition & 1 deletion custom_components/ta_coe/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,6 @@
"documentation": "https://github.com/DeerMaximum/Technische-Alternative-CoE",
"iot_class": "local_polling",
"issue_tracker": "https://github.com/DeerMaximum/Technische-Alternative-CoE/issues",
"requirements": ["ta-cmi==2.2.0"],
"requirements": ["ta-cmi==3.0.0"],
"version": "1.3.1"
}
60 changes: 60 additions & 0 deletions custom_components/ta_coe/repairs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Repairs for Technische Alternative CoE integration."""
from typing import Any

import homeassistant.helpers.config_validation as cv
import voluptuous as vol
from homeassistant import config_entries, data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant

from custom_components.ta_coe import CONF_CAN_IDS
from custom_components.ta_coe.config_flow import CANIDError, split_can_ids


class MissingCANIDRepairFlow(RepairsFlow):
"""Handler for an missing CAN-ID fixing flow."""

def __init__(self, config_entry: config_entries.ConfigEntry):
"""Initialize."""
self.config_entry = config_entry
self.config_data = dict(self.config_entry.data)

async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_form()

async def async_step_form(
self, user_input: dict[str, Any] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""

errors: dict[str, Any] = {}

if user_input is not None:
try:
self.config_data[CONF_CAN_IDS] = split_can_ids(user_input[CONF_CAN_IDS])
except CANIDError:
errors["base"] = "invalid_can_id"
else:
self.hass.config_entries.async_update_entry(
self.config_entry, data=self.config_data
)
return self.async_create_entry(title="", data={})

return self.async_show_form(
step_id="form",
data_schema=vol.Schema({vol.Required(CONF_CAN_IDS): cv.string}),
errors=errors,
)


async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: Any | None,
) -> RepairsFlow:
"""Entry point for repair flows."""
if issue_id == "add_missing_can_id":
return MissingCANIDRepairFlow(hass.config_entries.async_get_entry(data))
Loading

0 comments on commit 74f6514

Please sign in to comment.