2021-05-26 17:28:14 +00:00
|
|
|
|
"""Validate Modbus configuration."""
|
2021-05-28 09:38:31 +00:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2021-07-31 21:17:23 +00:00
|
|
|
|
from collections import namedtuple
|
2021-05-26 17:28:14 +00:00
|
|
|
|
import logging
|
|
|
|
|
import struct
|
2021-05-28 09:38:31 +00:00
|
|
|
|
from typing import Any
|
2021-05-26 17:28:14 +00:00
|
|
|
|
|
2021-05-28 09:38:31 +00:00
|
|
|
|
import voluptuous as vol
|
2021-05-26 17:28:14 +00:00
|
|
|
|
|
2021-05-28 09:38:31 +00:00
|
|
|
|
from homeassistant.const import (
|
2021-08-19 07:37:31 +00:00
|
|
|
|
CONF_ADDRESS,
|
2021-09-02 11:53:38 +00:00
|
|
|
|
CONF_COMMAND_OFF,
|
|
|
|
|
CONF_COMMAND_ON,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
CONF_COUNT,
|
2021-08-25 10:29:00 +00:00
|
|
|
|
CONF_HOST,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
CONF_NAME,
|
2021-08-25 10:29:00 +00:00
|
|
|
|
CONF_PORT,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
CONF_SCAN_INTERVAL,
|
2021-08-19 07:37:31 +00:00
|
|
|
|
CONF_SLAVE,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
CONF_STRUCTURE,
|
|
|
|
|
CONF_TIMEOUT,
|
2021-08-25 10:29:00 +00:00
|
|
|
|
CONF_TYPE,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
)
|
2021-05-26 17:28:14 +00:00
|
|
|
|
|
|
|
|
|
from .const import (
|
|
|
|
|
CONF_DATA_TYPE,
|
2023-09-15 11:49:33 +00:00
|
|
|
|
CONF_DEVICE_ADDRESS,
|
2021-09-06 20:35:40 +00:00
|
|
|
|
CONF_INPUT_TYPE,
|
2022-02-28 19:07:55 +00:00
|
|
|
|
CONF_SLAVE_COUNT,
|
2021-05-26 17:28:14 +00:00
|
|
|
|
CONF_SWAP,
|
|
|
|
|
CONF_SWAP_BYTE,
|
|
|
|
|
CONF_SWAP_NONE,
|
2023-09-03 19:04:58 +00:00
|
|
|
|
CONF_SWAP_WORD,
|
|
|
|
|
CONF_SWAP_WORD_BYTE,
|
2023-09-15 12:00:02 +00:00
|
|
|
|
CONF_VIRTUAL_COUNT,
|
2021-09-06 20:35:40 +00:00
|
|
|
|
CONF_WRITE_TYPE,
|
2021-08-25 10:29:00 +00:00
|
|
|
|
DEFAULT_HUB,
|
2021-05-28 09:38:31 +00:00
|
|
|
|
DEFAULT_SCAN_INTERVAL,
|
|
|
|
|
PLATFORMS,
|
2021-08-25 10:29:00 +00:00
|
|
|
|
SERIAL,
|
2021-10-15 05:09:59 +00:00
|
|
|
|
DataType,
|
2021-05-26 17:28:14 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
|
2023-09-03 19:04:58 +00:00
|
|
|
|
ENTRY = namedtuple(
|
|
|
|
|
"ENTRY",
|
|
|
|
|
[
|
|
|
|
|
"struct_id",
|
|
|
|
|
"register_count",
|
|
|
|
|
"validate_parm",
|
|
|
|
|
],
|
|
|
|
|
)
|
2023-11-26 16:49:51 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ILLEGAL = "I"
|
|
|
|
|
OPTIONAL = "O"
|
|
|
|
|
DEMANDED = "D"
|
|
|
|
|
|
2023-09-03 19:04:58 +00:00
|
|
|
|
PARM_IS_LEGAL = namedtuple(
|
|
|
|
|
"PARM_IS_LEGAL",
|
|
|
|
|
[
|
|
|
|
|
"count",
|
|
|
|
|
"structure",
|
|
|
|
|
"slave_count",
|
|
|
|
|
"swap_byte",
|
|
|
|
|
"swap_word",
|
|
|
|
|
],
|
|
|
|
|
)
|
2021-07-31 21:17:23 +00:00
|
|
|
|
DEFAULT_STRUCT_FORMAT = {
|
2023-11-26 16:49:51 +00:00
|
|
|
|
DataType.INT16: ENTRY(
|
|
|
|
|
"h", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.UINT16: ENTRY(
|
|
|
|
|
"H", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.FLOAT16: ENTRY(
|
|
|
|
|
"e", 1, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, ILLEGAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.INT32: ENTRY(
|
|
|
|
|
"i", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.UINT32: ENTRY(
|
|
|
|
|
"I", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.FLOAT32: ENTRY(
|
|
|
|
|
"f", 2, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.INT64: ENTRY(
|
|
|
|
|
"q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.UINT64: ENTRY(
|
|
|
|
|
"Q", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.FLOAT64: ENTRY(
|
|
|
|
|
"d", 4, PARM_IS_LEGAL(ILLEGAL, ILLEGAL, OPTIONAL, OPTIONAL, OPTIONAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.STRING: ENTRY(
|
|
|
|
|
"s", 0, PARM_IS_LEGAL(DEMANDED, ILLEGAL, ILLEGAL, OPTIONAL, ILLEGAL)
|
|
|
|
|
),
|
|
|
|
|
DataType.CUSTOM: ENTRY(
|
|
|
|
|
"?", 0, PARM_IS_LEGAL(DEMANDED, DEMANDED, ILLEGAL, ILLEGAL, ILLEGAL)
|
|
|
|
|
),
|
2021-07-31 21:17:23 +00:00
|
|
|
|
}
|
2021-07-12 05:58:45 +00:00
|
|
|
|
|
2021-05-26 17:28:14 +00:00
|
|
|
|
|
2021-09-21 11:43:41 +00:00
|
|
|
|
def struct_validator(config: dict[str, Any]) -> dict[str, Any]:
|
2021-05-26 17:28:14 +00:00
|
|
|
|
"""Sensor schema validator."""
|
|
|
|
|
|
2021-07-12 05:58:45 +00:00
|
|
|
|
name = config[CONF_NAME]
|
2023-09-03 19:04:58 +00:00
|
|
|
|
data_type = config[CONF_DATA_TYPE]
|
|
|
|
|
if data_type == "int":
|
|
|
|
|
data_type = config[CONF_DATA_TYPE] = DataType.INT16
|
|
|
|
|
count = config.get(CONF_COUNT, None)
|
|
|
|
|
structure = config.get(CONF_STRUCTURE, None)
|
2023-11-26 16:49:51 +00:00
|
|
|
|
slave_count = config.get(CONF_SLAVE_COUNT, config.get(CONF_VIRTUAL_COUNT))
|
2023-08-18 08:55:39 +00:00
|
|
|
|
swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE)
|
2023-09-03 19:04:58 +00:00
|
|
|
|
validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm
|
2023-11-08 08:55:00 +00:00
|
|
|
|
for entry in (
|
|
|
|
|
(count, validator.count, CONF_COUNT),
|
|
|
|
|
(structure, validator.structure, CONF_STRUCTURE),
|
2023-11-26 16:49:51 +00:00
|
|
|
|
(
|
|
|
|
|
slave_count,
|
|
|
|
|
validator.slave_count,
|
|
|
|
|
f"{CONF_VIRTUAL_COUNT} / {CONF_SLAVE_COUNT}",
|
|
|
|
|
),
|
2023-11-08 08:55:00 +00:00
|
|
|
|
):
|
2023-11-26 16:49:51 +00:00
|
|
|
|
if entry[0] is None:
|
|
|
|
|
if entry[1] == DEMANDED:
|
|
|
|
|
error = f"{name}: `{entry[2]}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`"
|
|
|
|
|
raise vol.Invalid(error)
|
|
|
|
|
elif entry[1] == ILLEGAL:
|
2023-11-08 08:55:00 +00:00
|
|
|
|
error = (
|
2023-11-26 16:49:51 +00:00
|
|
|
|
f"{name}: `{entry[2]}:` illegal with `{CONF_DATA_TYPE}: {data_type}`"
|
2023-11-08 08:55:00 +00:00
|
|
|
|
)
|
|
|
|
|
raise vol.Invalid(error)
|
|
|
|
|
|
2023-09-03 19:04:58 +00:00
|
|
|
|
if swap_type != CONF_SWAP_NONE:
|
|
|
|
|
swap_type_validator = {
|
2023-11-26 16:49:51 +00:00
|
|
|
|
CONF_SWAP_NONE: validator.swap_byte,
|
2023-09-03 19:04:58 +00:00
|
|
|
|
CONF_SWAP_BYTE: validator.swap_byte,
|
|
|
|
|
CONF_SWAP_WORD: validator.swap_word,
|
|
|
|
|
CONF_SWAP_WORD_BYTE: validator.swap_word,
|
|
|
|
|
}[swap_type]
|
2023-11-26 16:49:51 +00:00
|
|
|
|
if swap_type_validator == ILLEGAL:
|
|
|
|
|
error = f"{name}: `{CONF_SWAP}:{swap_type}` illegal with `{CONF_DATA_TYPE}: {data_type}`"
|
2021-07-21 05:49:54 +00:00
|
|
|
|
raise vol.Invalid(error)
|
2023-09-03 19:04:58 +00:00
|
|
|
|
if config[CONF_DATA_TYPE] == DataType.CUSTOM:
|
2021-07-12 05:58:45 +00:00
|
|
|
|
try:
|
|
|
|
|
size = struct.calcsize(structure)
|
|
|
|
|
except struct.error as err:
|
2023-09-03 19:04:58 +00:00
|
|
|
|
raise vol.Invalid(
|
|
|
|
|
f"{name}: error in structure format --> {str(err)}"
|
|
|
|
|
) from err
|
2021-07-12 05:58:45 +00:00
|
|
|
|
bytecount = count * 2
|
|
|
|
|
if bytecount != size:
|
2021-05-28 09:38:31 +00:00
|
|
|
|
raise vol.Invalid(
|
2023-09-03 19:04:58 +00:00
|
|
|
|
f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes"
|
2021-05-26 17:28:14 +00:00
|
|
|
|
)
|
2023-09-03 19:04:58 +00:00
|
|
|
|
else:
|
2023-10-06 12:10:10 +00:00
|
|
|
|
if data_type != DataType.STRING:
|
|
|
|
|
config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count
|
2023-09-03 19:04:58 +00:00
|
|
|
|
if slave_count:
|
|
|
|
|
structure = (
|
|
|
|
|
f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}"
|
2023-08-18 08:55:39 +00:00
|
|
|
|
)
|
2023-09-03 19:04:58 +00:00
|
|
|
|
else:
|
|
|
|
|
structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}"
|
2021-05-26 17:28:14 +00:00
|
|
|
|
return {
|
|
|
|
|
**config,
|
|
|
|
|
CONF_STRUCTURE: structure,
|
|
|
|
|
CONF_SWAP: swap_type,
|
|
|
|
|
}
|
2021-05-28 09:38:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def number_validator(value: Any) -> int | float:
|
|
|
|
|
"""Coerce a value to number without losing precision."""
|
|
|
|
|
if isinstance(value, int):
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(value, float):
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
try:
|
2021-09-21 11:43:41 +00:00
|
|
|
|
return int(value)
|
2021-05-28 09:38:31 +00:00
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
pass
|
|
|
|
|
try:
|
2021-09-21 11:43:41 +00:00
|
|
|
|
return float(value)
|
2021-05-28 09:38:31 +00:00
|
|
|
|
except (TypeError, ValueError) as err:
|
|
|
|
|
raise vol.Invalid(f"invalid number {value}") from err
|
|
|
|
|
|
|
|
|
|
|
2023-08-06 11:47:54 +00:00
|
|
|
|
def nan_validator(value: Any) -> int:
|
|
|
|
|
"""Convert nan string to number (can be hex string or int)."""
|
|
|
|
|
if isinstance(value, int):
|
|
|
|
|
return value
|
|
|
|
|
try:
|
|
|
|
|
return int(value)
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
pass
|
|
|
|
|
try:
|
|
|
|
|
return int(value, 16)
|
|
|
|
|
except (TypeError, ValueError) as err:
|
|
|
|
|
raise vol.Invalid(f"invalid number {value}") from err
|
|
|
|
|
|
|
|
|
|
|
2021-05-28 09:38:31 +00:00
|
|
|
|
def scan_interval_validator(config: dict) -> dict:
|
|
|
|
|
"""Control scan_interval."""
|
|
|
|
|
for hub in config:
|
|
|
|
|
minimum_scan_interval = DEFAULT_SCAN_INTERVAL
|
|
|
|
|
for component, conf_key in PLATFORMS:
|
|
|
|
|
if conf_key not in hub:
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
for entry in hub[conf_key]:
|
|
|
|
|
scan_interval = entry.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
|
2021-06-04 16:06:44 +00:00
|
|
|
|
if scan_interval == 0:
|
|
|
|
|
continue
|
|
|
|
|
if scan_interval < 5:
|
2021-05-28 09:38:31 +00:00
|
|
|
|
_LOGGER.warning(
|
2022-12-22 12:35:47 +00:00
|
|
|
|
(
|
|
|
|
|
"%s %s scan_interval(%d) is lower than 5 seconds, "
|
|
|
|
|
"which may cause Home Assistant stability issues"
|
|
|
|
|
),
|
2021-05-28 09:38:31 +00:00
|
|
|
|
component,
|
|
|
|
|
entry.get(CONF_NAME),
|
|
|
|
|
scan_interval,
|
|
|
|
|
)
|
|
|
|
|
entry[CONF_SCAN_INTERVAL] = scan_interval
|
|
|
|
|
minimum_scan_interval = min(scan_interval, minimum_scan_interval)
|
2021-06-04 16:06:44 +00:00
|
|
|
|
if (
|
|
|
|
|
CONF_TIMEOUT in hub
|
|
|
|
|
and hub[CONF_TIMEOUT] > minimum_scan_interval - 1
|
|
|
|
|
and minimum_scan_interval > 1
|
|
|
|
|
):
|
2021-05-28 09:38:31 +00:00
|
|
|
|
_LOGGER.warning(
|
|
|
|
|
"Modbus %s timeout(%d) is adjusted(%d) due to scan_interval",
|
|
|
|
|
hub.get(CONF_NAME, ""),
|
|
|
|
|
hub[CONF_TIMEOUT],
|
|
|
|
|
minimum_scan_interval - 1,
|
|
|
|
|
)
|
2021-06-04 16:06:44 +00:00
|
|
|
|
hub[CONF_TIMEOUT] = minimum_scan_interval - 1
|
2021-05-28 09:38:31 +00:00
|
|
|
|
return config
|
2021-08-19 07:37:31 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def duplicate_entity_validator(config: dict) -> dict:
|
|
|
|
|
"""Control scan_interval."""
|
|
|
|
|
for hub_index, hub in enumerate(config):
|
|
|
|
|
for component, conf_key in PLATFORMS:
|
|
|
|
|
if conf_key not in hub:
|
|
|
|
|
continue
|
|
|
|
|
names: set[str] = set()
|
|
|
|
|
errors: list[int] = []
|
2021-09-02 11:53:38 +00:00
|
|
|
|
addresses: set[str] = set()
|
2021-08-19 07:37:31 +00:00
|
|
|
|
for index, entry in enumerate(hub[conf_key]):
|
|
|
|
|
name = entry[CONF_NAME]
|
|
|
|
|
addr = str(entry[CONF_ADDRESS])
|
2021-09-06 20:35:40 +00:00
|
|
|
|
if CONF_INPUT_TYPE in entry:
|
|
|
|
|
addr += "_" + str(entry[CONF_INPUT_TYPE])
|
|
|
|
|
elif CONF_WRITE_TYPE in entry:
|
|
|
|
|
addr += "_" + str(entry[CONF_WRITE_TYPE])
|
2021-09-02 11:53:38 +00:00
|
|
|
|
if CONF_COMMAND_ON in entry:
|
|
|
|
|
addr += "_" + str(entry[CONF_COMMAND_ON])
|
|
|
|
|
if CONF_COMMAND_OFF in entry:
|
|
|
|
|
addr += "_" + str(entry[CONF_COMMAND_OFF])
|
2023-09-15 11:49:33 +00:00
|
|
|
|
inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0)
|
|
|
|
|
addr += "_" + str(inx)
|
2021-08-19 07:37:31 +00:00
|
|
|
|
if addr in addresses:
|
2022-12-22 12:35:47 +00:00
|
|
|
|
err = (
|
|
|
|
|
f"Modbus {component}/{name} address {addr} is duplicate, second"
|
|
|
|
|
" entry not loaded!"
|
|
|
|
|
)
|
2021-08-19 07:37:31 +00:00
|
|
|
|
_LOGGER.warning(err)
|
|
|
|
|
errors.append(index)
|
|
|
|
|
elif name in names:
|
2022-12-22 12:35:47 +00:00
|
|
|
|
err = (
|
|
|
|
|
f"Modbus {component}/{name} is duplicate, second entry not"
|
|
|
|
|
" loaded!"
|
|
|
|
|
)
|
2021-08-19 07:37:31 +00:00
|
|
|
|
_LOGGER.warning(err)
|
|
|
|
|
errors.append(index)
|
|
|
|
|
else:
|
|
|
|
|
names.add(name)
|
|
|
|
|
addresses.add(addr)
|
|
|
|
|
|
|
|
|
|
for i in reversed(errors):
|
|
|
|
|
del config[hub_index][conf_key][i]
|
2021-08-25 10:29:00 +00:00
|
|
|
|
return config
|
|
|
|
|
|
2021-08-19 07:37:31 +00:00
|
|
|
|
|
2021-08-25 10:29:00 +00:00
|
|
|
|
def duplicate_modbus_validator(config: list) -> list:
|
|
|
|
|
"""Control modbus connection for duplicates."""
|
|
|
|
|
hosts: set[str] = set()
|
|
|
|
|
names: set[str] = set()
|
|
|
|
|
errors = []
|
|
|
|
|
for index, hub in enumerate(config):
|
|
|
|
|
name = hub.get(CONF_NAME, DEFAULT_HUB)
|
2021-09-06 20:40:15 +00:00
|
|
|
|
if hub[CONF_TYPE] == SERIAL:
|
|
|
|
|
host = hub[CONF_PORT]
|
|
|
|
|
else:
|
|
|
|
|
host = f"{hub[CONF_HOST]}_{hub[CONF_PORT]}"
|
2021-08-25 10:29:00 +00:00
|
|
|
|
if host in hosts:
|
|
|
|
|
err = f"Modbus {name} contains duplicate host/port {host}, not loaded!"
|
|
|
|
|
_LOGGER.warning(err)
|
|
|
|
|
errors.append(index)
|
|
|
|
|
elif name in names:
|
|
|
|
|
err = f"Modbus {name} is duplicate, second entry not loaded!"
|
|
|
|
|
_LOGGER.warning(err)
|
|
|
|
|
errors.append(index)
|
|
|
|
|
else:
|
|
|
|
|
hosts.add(host)
|
|
|
|
|
names.add(name)
|
|
|
|
|
|
|
|
|
|
for i in reversed(errors):
|
|
|
|
|
del config[i]
|
2021-08-19 07:37:31 +00:00
|
|
|
|
return config
|