add domain folder
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
""" Initialisation du package de l'intégration TestVBE_4 """
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
from .koolnova.device import Koolnova
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
|
||||
from .coordinator import KoolnovaCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant,
|
||||
entry: ConfigEntry) -> bool: # pylint: disable=unused-argument
|
||||
""" Creation des entités à partir d'une configEntry """
|
||||
|
||||
#hass.data.setdefault(DOMAIN, [])
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
|
||||
name: str = entry.data['Name']
|
||||
port: str = entry.data['Device']
|
||||
addr: int = entry.data['Address']
|
||||
baudrate: int = entry.data['Baudrate']
|
||||
parity: str = entry.data['Parity'][0]
|
||||
bytesize: int = entry.data['Sizebyte']
|
||||
stopbits: int = entry.data['Stopbits']
|
||||
timeout: int = entry.data['Timeout']
|
||||
|
||||
try:
|
||||
device = Koolnova(name, port, addr, baudrate, parity, bytesize, stopbits, timeout)
|
||||
# connect to modbus client
|
||||
await device.async_connect()
|
||||
# update attributes
|
||||
await device.async_update()
|
||||
# record each area in device
|
||||
_LOGGER.debug("Koolnova areas: {}".format(entry.data['areas']))
|
||||
for area in entry.data['areas']:
|
||||
await device.async_add_manual_registered_area(name=area['Name'],
|
||||
id_zone=area['Area_id'])
|
||||
hass.data[DOMAIN]['device'] = device
|
||||
coordinator = KoolnovaCoordinator(hass, device)
|
||||
hass.data[DOMAIN]['coordinator'] = coordinator
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Something went wrong ... {}".format(e))
|
||||
|
||||
# Propagation du configEntry à toutes les plateformes déclarées dans notre intégration
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
""" Handle removal of an entry """
|
||||
_LOGGER.debug("Appel de async_remove_entry - entry: {}".format(entry))
|
||||
@@ -0,0 +1,181 @@
|
||||
""" for Climate integration. """
|
||||
from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ConfigEntry,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from homeassistant.components.climate.const import (
|
||||
HVACMode,
|
||||
FAN_AUTO,
|
||||
FAN_OFF,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SUPPORT_FLAGS,
|
||||
SUPPORTED_HVAC_MODES,
|
||||
SUPPORTED_FAN_MODES,
|
||||
FAN_TRANSLATION,
|
||||
HVAC_TRANSLATION,
|
||||
)
|
||||
|
||||
from .coordinator import KoolnovaCoordinator
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
|
||||
from .koolnova.device import Koolnova, Area
|
||||
from .koolnova.const import (
|
||||
MIN_TEMP,
|
||||
MAX_TEMP,
|
||||
STEP_TEMP,
|
||||
MIN_TEMP_ORDER,
|
||||
MAX_TEMP_ORDER,
|
||||
STEP_TEMP_ORDER,
|
||||
SysState,
|
||||
ZoneState,
|
||||
ZoneClimMode,
|
||||
ZoneFanMode,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Setup switch entries"""
|
||||
|
||||
entities = []
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
device = hass.data[DOMAIN]["device"]
|
||||
|
||||
for area in device.areas:
|
||||
entities.append(AreaClimateEntity(coordinator, device, area))
|
||||
async_add_entities(entities)
|
||||
|
||||
class AreaClimateEntity(CoordinatorEntity, ClimateEntity):
|
||||
""" Reperesentation of a climate entity """
|
||||
# pylint: disable = too-many-instance-attributes
|
||||
|
||||
_attr_supported_features: int = SUPPORT_FLAGS
|
||||
_attr_temperature_unit: str = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes: list[HVACMode] = SUPPORTED_HVAC_MODES
|
||||
_attr_fan_modes: list[str] = SUPPORTED_FAN_MODES
|
||||
_attr_hvac_mode: HVACMode = HVACMode.OFF
|
||||
_attr_fan_mode: str = FAN_OFF
|
||||
_attr_min_temp: float = MIN_TEMP
|
||||
_attr_max_temp: float = MAX_TEMP
|
||||
_attr_precision: float = STEP_TEMP
|
||||
_attr_target_temperature_high: float = MAX_TEMP_ORDER
|
||||
_attr_target_temperature_low: float = MIN_TEMP_ORDER
|
||||
_attr_target_temperature_step: float = STEP_TEMP_ORDER
|
||||
_enable_turn_on_off_backwards_compatibility: bool = False
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument
|
||||
area: Area, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._area = area
|
||||
self._attr_name = f"{device.name} {area.name} area"
|
||||
self._attr_device_info = device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-{area.name}-area-climate"
|
||||
self._attr_current_temperature = area.real_temp
|
||||
self._attr_target_temperature = area.order_temp
|
||||
self._attr_fan_mode = FAN_TRANSLATION[int(self._area.fan_mode)]
|
||||
self._attr_hvac_mode = self._translate_to_hvac_mode()
|
||||
|
||||
def _translate_to_hvac_mode(self) -> int:
|
||||
""" translate area state and clim mode to HA hvac mode """
|
||||
ret = 0
|
||||
if self._area.state == ZoneState.STATE_OFF:
|
||||
ret = HVACMode.OFF
|
||||
else:
|
||||
ret = HVAC_TRANSLATION[int(self._area.clim_mode)]
|
||||
|
||||
return ret
|
||||
|
||||
async def async_set_temperature(self,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
""" set new target temperature """
|
||||
_LOGGER.debug("[Climate {}] set target temp - kwargs: {}".format(self._area.id_zone, kwargs))
|
||||
if "temperature" in kwargs:
|
||||
target_temp = kwargs.get("temperature")
|
||||
ret = await self._device.async_set_area_target_temp(zone_id = self._area.id_zone, temp = target_temp)
|
||||
if not ret:
|
||||
_LOGGER.error("Error sending target temperature for area id {}".format(self._area.id_zone))
|
||||
else:
|
||||
_LOGGER.warning("Target temperature not defined for climate id {}".format(self._area.id_zone))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_fan_mode(self,
|
||||
fan_mode:str,
|
||||
) -> None:
|
||||
""" set new target fan mode """
|
||||
_LOGGER.debug("[Climate {}] set new fan mode: {}".format(self._area.id_zone, fan_mode))
|
||||
for k,v in FAN_TRANSLATION.items():
|
||||
if v == fan_mode:
|
||||
opt = k
|
||||
break
|
||||
ret = await self._device.async_set_area_fan_mode(zone_id = self._area.id_zone,
|
||||
mode = ZoneFanMode(opt))
|
||||
if not ret:
|
||||
_LOGGER.exception("Error setting new fan value for area id {}".format(self._area.id_zone))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_set_hvac_mode(self,
|
||||
hvac_mode:HVACMode,
|
||||
) -> None:
|
||||
""" set new target hvac mode """
|
||||
_LOGGER.debug("[Climate {}] set new hvac mode: {}".format(self._area.id_zone, hvac_mode))
|
||||
opt = 0
|
||||
for k,v in HVAC_TRANSLATION.items():
|
||||
if v == hvac_mode:
|
||||
opt = k
|
||||
break
|
||||
ret = await self._device.async_set_area_clim_mode(zone_id = self._area.id_zone,
|
||||
mode = ZoneClimMode(opt))
|
||||
if not ret:
|
||||
_LOGGER.exception("Error setting new hvac value for area id {}".format(self._area.id_zone))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator """
|
||||
for _cur_area in self.coordinator.data['areas']:
|
||||
if _cur_area.id_zone == self._area.id_zone:
|
||||
_LOGGER.debug("[UPDATE] [Climate {}] temp:{} - target:{} - state: {} - hvac:{} - fan:{}".format(_cur_area.id_zone,
|
||||
_cur_area.real_temp,
|
||||
_cur_area.order_temp,
|
||||
_cur_area.state,
|
||||
_cur_area.clim_mode,
|
||||
_cur_area.fan_mode))
|
||||
self._area = _cur_area
|
||||
self._attr_current_temperature = _cur_area.real_temp
|
||||
self._attr_target_temperature = _cur_area.order_temp
|
||||
if _cur_area.state == ZoneState.STATE_OFF:
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
else:
|
||||
self._attr_hvac_mode = HVAC_TRANSLATION[int(_cur_area.clim_mode)]
|
||||
self._attr_fan_mode = FAN_TRANSLATION[int(_cur_area.fan_mode)]
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,206 @@
|
||||
""" Le Config Flow """
|
||||
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import exceptions
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.config_entries import ConfigFlow
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.const import CONF_BASE
|
||||
from .const import DOMAIN, CONF_NAME
|
||||
|
||||
from .koolnova.operations import Operations
|
||||
from .koolnova.const import (
|
||||
DEFAULT_ADDR,
|
||||
DEFAULT_BAUDRATE,
|
||||
DEFAULT_PARITY,
|
||||
DEFAULT_STOPBITS,
|
||||
DEFAULT_BYTESIZE,
|
||||
NB_ZONE_MAX
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class TestVBE4ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
""" La classe qui implémente le config flow notre DOMAIN.
|
||||
Elle doit dériver de FlowHandler
|
||||
"""
|
||||
|
||||
# La version de notre configFlow va permettre de migrer les entités
|
||||
# vers une version plus récente en cas de changement
|
||||
VERSION = 1
|
||||
# le dictionnaire qui va recevoir tous les user_input. On le vide au démarrage
|
||||
_user_inputs: dict = {}
|
||||
_conn = None
|
||||
|
||||
async def async_step_user(self,
|
||||
user_input: dict | None = None) -> FlowResult:
|
||||
""" Gestion de l'étape 'user'.
|
||||
Point d'entrée de mon configFlow. Cette méthode est appelée 2 fois:
|
||||
1. 1ere fois sans user_input -> Affichage du formulaire de configuration
|
||||
2. 2eme fois avec les données saisies par l'utilisateur dans user_input -> Sauvegarde des données saisies
|
||||
"""
|
||||
errors = {}
|
||||
user_form = vol.Schema( #pylint: disable=invalid-name
|
||||
{
|
||||
vol.Required("Name", default="koolnova"): vol.Coerce(str),
|
||||
vol.Required("Device", default="/dev/ttyUSB0"): vol.Coerce(str),
|
||||
vol.Required("Address", default=DEFAULT_ADDR): vol.Coerce(int),
|
||||
vol.Required("Baudrate", default=str(DEFAULT_BAUDRATE)): vol.In(["9600", "19200"]),
|
||||
vol.Required("Sizebyte", default=DEFAULT_BYTESIZE): vol.Coerce(int),
|
||||
vol.Required("Parity", default="EVEN"): vol.In(["EVEN", "NONE"]),
|
||||
vol.Required("Stopbits", default=DEFAULT_STOPBITS): vol.Coerce(int),
|
||||
vol.Required("Timeout", default=1): vol.Coerce(int),
|
||||
vol.Optional("Debug", default=False): cv.boolean
|
||||
}
|
||||
)
|
||||
|
||||
if user_input:
|
||||
_LOGGER.debug("config_flow [user] - Step 1b -> On a reçu les valeurs: {}".format(user_input))
|
||||
# Second call; On memorise les données dans le dictionnaire
|
||||
self._user_inputs.update(user_input)
|
||||
|
||||
self._conn = Operations(port=self._user_inputs["Device"],
|
||||
addr=self._user_inputs["Address"],
|
||||
baudrate=int(self._user_inputs["Baudrate"]),
|
||||
parity=self._user_inputs["Parity"][0],
|
||||
bytesize=self._user_inputs["Sizebyte"],
|
||||
stopbits=self._user_inputs["Stopbits"],
|
||||
timeout=self._user_inputs["Timeout"],
|
||||
debug=self._user_inputs["Debug"])
|
||||
try:
|
||||
await self._conn.async_connect()
|
||||
if not self._conn.connected():
|
||||
raise CannotConnectError(reason="Client Modbus not connected")
|
||||
#_LOGGER.debug("test communication with koolnova system")
|
||||
ret, _ = await self._conn.async_system_status()
|
||||
if not ret:
|
||||
self._conn.disconnect()
|
||||
raise CannotConnectError(reason="Communication error")
|
||||
self._conn.disconnect()
|
||||
|
||||
self._user_inputs["areas"] = []
|
||||
# go to next step
|
||||
return await self.async_step_areas()
|
||||
except CannotConnectError:
|
||||
_LOGGER.exception("Cannot connect to koolnova system")
|
||||
errors[CONF_BASE] = "cannot_connect"
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Config Flow generic error")
|
||||
|
||||
# first call or error
|
||||
return self.async_show_form(step_id="user",
|
||||
data_schema=user_form,
|
||||
errors=errors)
|
||||
|
||||
async def async_step_areas(self,
|
||||
user_input: dict | None = None) -> FlowResult:
|
||||
""" Gestion de l'étape de découverte manuelle des zones """
|
||||
errors = {}
|
||||
default_id = 1
|
||||
default_area_name = "Area 1"
|
||||
# set default_id to the last id configured
|
||||
# set default_area_name with the last id configured
|
||||
for area in self._user_inputs["areas"]:
|
||||
default_id = area['Area_id'] + 1
|
||||
default_area_name = "Area " + str(default_id)
|
||||
zone_form = vol.Schema(
|
||||
{
|
||||
vol.Required("Name", default=default_area_name): vol.Coerce(str),
|
||||
vol.Required("Area_id", default=default_id): vol.Coerce(int),
|
||||
vol.Optional("Other_area", default=False): cv.boolean
|
||||
}
|
||||
)
|
||||
|
||||
if user_input:
|
||||
# second call
|
||||
try:
|
||||
# test if area_id is already configured
|
||||
for area in self._user_inputs["areas"]:
|
||||
if user_input['Area_id'] == area['Area_id']:
|
||||
raise AreaAlreadySetError(reason="Area is already configured")
|
||||
# Last area to configure or not ?
|
||||
if not user_input['Other_area']:
|
||||
try:
|
||||
if not self._conn.connected():
|
||||
await self._conn.async_connect()
|
||||
if user_input['Area_id'] > NB_ZONE_MAX:
|
||||
raise ZoneIdError(reason="Area_id must be between 1 and 16")
|
||||
#_LOGGER.debug("test area registered with id: {}".format(user_input['Area_id']))
|
||||
# test if area is configured into koolnova system
|
||||
ret, _ = await self._conn.async_area_registered(user_input["Area_id"])
|
||||
if not ret:
|
||||
self._conn.disconnect()
|
||||
raise AreaNotRegistredError(reason="Area Id is not registred")
|
||||
|
||||
self._conn.disconnect()
|
||||
# Update dict
|
||||
self._user_inputs["areas"].append(user_input)
|
||||
# Create entities
|
||||
return self.async_create_entry(title=CONF_NAME,
|
||||
data=self._user_inputs)
|
||||
except CannotConnectError:
|
||||
_LOGGER.exception("Cannot connect to koolnova system")
|
||||
errors[CONF_BASE] = "cannot_connect"
|
||||
except AreaNotRegistredError:
|
||||
_LOGGER.exception("Area (id:{}) is not registered to the koolnova system".format(user_input['Area_id']))
|
||||
errors[CONF_BASE] = "area_not_registered"
|
||||
except ZoneIdError:
|
||||
_LOGGER.exception("Area Id must be between 1 and 16")
|
||||
errors[CONF_BASE] = "zone_id_error"
|
||||
except Exception as e:
|
||||
_LOGGER.exception("Config Flow generic error")
|
||||
else:
|
||||
#_LOGGER.debug("Config_flow [zone] - Une autre zone à configurer")
|
||||
# Update dict
|
||||
self._user_inputs["areas"].append(user_input)
|
||||
# New area to configure
|
||||
return await self.async_step_areas()
|
||||
|
||||
except AreaAlreadySetError:
|
||||
_LOGGER.exception("Area (id:{}) is already configured".format(user_input['Area_id']))
|
||||
errors[CONF_BASE] = "area_already_configured"
|
||||
|
||||
# first call or error
|
||||
return self.async_show_form(step_id="areas",
|
||||
data_schema=zone_form,
|
||||
errors=errors)
|
||||
|
||||
class KnownError(exceptions.HomeAssistantError):
|
||||
""" Base class for errors known to this config flow
|
||||
[error_name] is the value passed to [errors] in async_show_form, which should match
|
||||
a key under "errors" in string.json
|
||||
|
||||
[applies_to_field] is the name of the field name that contains the error (for async_show_form)
|
||||
if the field doesn't exist in the form, CONF_BASE will be used instead.
|
||||
"""
|
||||
error_name = "unknown_error"
|
||||
applies_to_field = CONF_BASE
|
||||
|
||||
def __init__(self, *args: object, **kwargs: dict[str, str]) -> None:
|
||||
super().__init__(*args)
|
||||
self._extra_info = kwargs
|
||||
|
||||
def get_errors_and_placeholders(self, schema):
|
||||
""" Return dicts of errors and description_placeholders for adding to async_show_form """
|
||||
key = self.applies_to_field
|
||||
if key not in {k.schema for k in schema}:
|
||||
key = CONF_BASE
|
||||
return ({key: self.error_name}, self._extra_info or {})
|
||||
|
||||
class CannotConnectError(KnownError):
|
||||
""" Error to indicate we cannot connect """
|
||||
error_name = "cannot_connect"
|
||||
|
||||
class AreaNotRegistredError(KnownError):
|
||||
""" Error to indicate that area is not registered """
|
||||
error_name = "area_not_registered"
|
||||
|
||||
class AreaAlreadySetError(KnownError):
|
||||
""" Error to indicate that the area is already configured """
|
||||
error_name = "area_already_configured"
|
||||
|
||||
class ZoneIdError(KnownError):
|
||||
""" Error with the Zone_Id """
|
||||
error_name = "zone_id_error"
|
||||
@@ -0,0 +1,136 @@
|
||||
""" Les constantes pour l'intégration TestVBE_4 """
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.climate.const import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
FAN_AUTO,
|
||||
FAN_OFF,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_HIGH,
|
||||
)
|
||||
from .koolnova.const import (
|
||||
GlobalMode,
|
||||
Efficiency,
|
||||
FlowEngine,
|
||||
ZoneClimMode,
|
||||
ZoneFanMode,
|
||||
ZoneState,
|
||||
)
|
||||
|
||||
DOMAIN = "testVBE_4"
|
||||
PLATFORMS: list[Platform] = [Platform.SENSOR,
|
||||
Platform.SELECT,
|
||||
Platform.SWITCH,
|
||||
Platform.CLIMATE]
|
||||
|
||||
CONF_NAME = "koolnova_test_HA"
|
||||
CONF_DEVICE_ID = "device_id"
|
||||
|
||||
DEVICE_MANUFACTURER = "koolnova"
|
||||
|
||||
#MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
GLOBAL_MODE_POS_1 = "cold"
|
||||
GLOBAL_MODE_POS_2 = "heat"
|
||||
GLOBAL_MODE_POS_3 = "heating floor"
|
||||
GLOBAL_MODE_POS_4 = "refreshing floor"
|
||||
GLOBAL_MODE_POS_5 = "heating floor 2"
|
||||
|
||||
GLOBAL_MODE_TRANSLATION = {
|
||||
int(GlobalMode.COLD): GLOBAL_MODE_POS_1,
|
||||
int(GlobalMode.HEAT): GLOBAL_MODE_POS_2,
|
||||
int(GlobalMode.HEATING_FLOOR): GLOBAL_MODE_POS_3,
|
||||
int(GlobalMode.REFRESHING_FLOOR): GLOBAL_MODE_POS_4,
|
||||
int(GlobalMode.HEATING_FLOOR_2): GLOBAL_MODE_POS_5,
|
||||
}
|
||||
|
||||
GLOBAL_MODES = [
|
||||
GLOBAL_MODE_POS_1,
|
||||
GLOBAL_MODE_POS_2,
|
||||
GLOBAL_MODE_POS_3,
|
||||
GLOBAL_MODE_POS_4,
|
||||
GLOBAL_MODE_POS_5,
|
||||
]
|
||||
|
||||
EFF_POS_1 = "lower"
|
||||
EFF_POS_2 = "low"
|
||||
EFF_POS_3 = "Medium"
|
||||
EFF_POS_4 = "High"
|
||||
EFF_POS_5 = "Higher"
|
||||
|
||||
EFF_TRANSLATION = {
|
||||
int(Efficiency.LOWER_EFF): EFF_POS_1,
|
||||
int(Efficiency.LOW_EFF): EFF_POS_2,
|
||||
int(Efficiency.MED_EFF): EFF_POS_3,
|
||||
int(Efficiency.HIGH_EFF): EFF_POS_4,
|
||||
int(Efficiency.HIGHER_EFF): EFF_POS_5,
|
||||
}
|
||||
|
||||
EFF_MODES = [
|
||||
EFF_POS_1,
|
||||
EFF_POS_2,
|
||||
EFF_POS_3,
|
||||
EFF_POS_4,
|
||||
EFF_POS_5,
|
||||
]
|
||||
|
||||
ENGINE_FLOW_POS_1 = "Manual minimum"
|
||||
ENGINE_FLOW_POS_2 = "Manual medium"
|
||||
ENGINE_FLOW_POS_3 = "Manual High"
|
||||
ENGINE_FLOW_POS_4 = "Auto"
|
||||
|
||||
ENGINE_FLOW_TRANSLATION = {
|
||||
int(FlowEngine.MANUAL_MIN): ENGINE_FLOW_POS_1,
|
||||
int(FlowEngine.MANUAL_MED): ENGINE_FLOW_POS_2,
|
||||
int(FlowEngine.MANUAL_HIGH): ENGINE_FLOW_POS_3,
|
||||
int(FlowEngine.AUTO): ENGINE_FLOW_POS_4,
|
||||
}
|
||||
|
||||
ENGINE_FLOW_MODES = [
|
||||
ENGINE_FLOW_POS_1,
|
||||
ENGINE_FLOW_POS_2,
|
||||
ENGINE_FLOW_POS_3,
|
||||
ENGINE_FLOW_POS_4,
|
||||
]
|
||||
|
||||
SUPPORT_FLAGS = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
| ClimateEntityFeature.FAN_MODE
|
||||
)
|
||||
|
||||
SUPPORTED_HVAC_MODES = [
|
||||
HVACMode.OFF,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
]
|
||||
|
||||
HVAC_TRANSLATION = {
|
||||
int(ZoneState.STATE_OFF): HVACMode.OFF,
|
||||
int(ZoneClimMode.COOL): HVACMode.COOL,
|
||||
int(ZoneClimMode.HEAT): HVACMode.HEAT,
|
||||
}
|
||||
|
||||
#FAN_MODE_1 = "1 Off"
|
||||
#FAN_MODE_2 = "2 Low"
|
||||
#FAN_MODE_3 = "3 Medium"
|
||||
#FAN_MODE_4 = "4 High"
|
||||
|
||||
FAN_TRANSLATION = {
|
||||
int(ZoneFanMode.FAN_AUTO): FAN_AUTO,
|
||||
int(ZoneFanMode.FAN_OFF): FAN_OFF,
|
||||
int(ZoneFanMode.FAN_LOW): FAN_LOW,
|
||||
int(ZoneFanMode.FAN_MEDIUM): FAN_MEDIUM,
|
||||
int(ZoneFanMode.FAN_HIGH): FAN_HIGH,
|
||||
}
|
||||
|
||||
SUPPORTED_FAN_MODES = [
|
||||
FAN_AUTO,
|
||||
FAN_OFF,
|
||||
FAN_LOW,
|
||||
FAN_MEDIUM,
|
||||
FAN_HIGH,
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
""" for Coordinator integration. """
|
||||
from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
DataUpdateCoordinator,
|
||||
UpdateFailed,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
from .koolnova.device import Koolnova
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class KoolnovaCoordinator(DataUpdateCoordinator):
|
||||
""" koolnova coordinator """
|
||||
|
||||
def __init__(self,
|
||||
hass: HomeAssistant,
|
||||
device: Koolnova,
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
# Name of the data. For logging purposes.
|
||||
name=DOMAIN,
|
||||
update_method=device.async_update_all_areas,
|
||||
# Polling interval. Will only be polled if there are subscribers.
|
||||
update_interval=timedelta(seconds=30),
|
||||
)
|
||||
@@ -0,0 +1,149 @@
|
||||
# Constants for koolnova Modbus RTU BMS
|
||||
# d'après la documentation officielle A52102 - Registre contrôle Modbus
|
||||
|
||||
from enum import Enum
|
||||
|
||||
# La couche physique est Modbus RTU sur RS485 à 9600, avec 8 bits de données, sans parité
|
||||
# ou même parité et un bit d'arrêt. Par défaut: 9600 8E1
|
||||
# L'adresse Modbus par défaut est 49
|
||||
DEFAULT_ADDR = 49
|
||||
DEFAULT_BAUDRATE = 9600
|
||||
DEFAULT_PARITY = 'E'
|
||||
DEFAULT_STOPBITS = 1
|
||||
DEFAULT_BYTESIZE = 8
|
||||
|
||||
# Nombre de machines (AC1, AC2, AC3 et AC4)
|
||||
NUM_OF_ENGINES = 4
|
||||
# Nombre de registres par zone
|
||||
NUM_REG_PER_ZONE = 4
|
||||
# Nombre max de zone pour un systeme
|
||||
NB_ZONE_MAX = 16
|
||||
|
||||
# Chaque zone climatique est définie par 4 registres et il y a 16 zones possibles,
|
||||
# donc le climat est défini par 64 registres
|
||||
REG_START_ZONE = 0
|
||||
|
||||
# Commandes Modbus prises en charge sont le Read Holding Register (0x03)
|
||||
# et le Write Single Register (0x06)
|
||||
|
||||
REG_LOCK_ZONE = 0 # 40001, 40005, 40009, etc ...
|
||||
REG_STATE_AND_FLOW = 1 # 40002, 40006, 40010, etc ...
|
||||
|
||||
class ZoneState(Enum):
|
||||
STATE_OFF = 0
|
||||
STATE_ON = 1
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
class ZoneRegister(Enum):
|
||||
REGISTER_OFF = 0
|
||||
REGISTER_ON = 1
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
class ZoneFanMode(Enum):
|
||||
FAN_OFF = 0
|
||||
FAN_LOW = 1
|
||||
FAN_MEDIUM = 2
|
||||
FAN_HIGH = 3
|
||||
FAN_AUTO = 4
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
class ZoneClimMode(Enum):
|
||||
OFF = 0
|
||||
COOL = 1
|
||||
HEAT = 2
|
||||
HEATING_FLOOR = 4
|
||||
REFRESHING_FLOOR = 5
|
||||
HEATING_FLOOR_2 = 6
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
# Température de consigne = (data / 2) => delta: 15°C -> 35°C
|
||||
REG_TEMP_ORDER = 2 # 40003, 40007, 40011, etc ...
|
||||
# Température réelle = (data / 2) => delta: 0°C -> 50°C
|
||||
REG_TEMP_REAL = 3 # 40004, 40008, 40012, etc ...
|
||||
|
||||
# Temperature maximale de consigne : 35°C
|
||||
MAX_TEMP_ORDER = 35.0
|
||||
# Temperature minimale de consigne : 15°C
|
||||
MIN_TEMP_ORDER = 15.0
|
||||
# Pas de la temperature de consigne : 0.5°C
|
||||
STEP_TEMP_ORDER = 0.5
|
||||
|
||||
# Temperature maximale : 50°C
|
||||
MAX_TEMP = 50.0
|
||||
# Temperature minimale : 0°C
|
||||
MIN_TEMP = 0.0
|
||||
# Pas de la temperature : 0.5°C
|
||||
STEP_TEMP = 0.5
|
||||
|
||||
# (4 registres: 64 -> 67) Debit des machines (0: arret -> 15: debit maximum)
|
||||
REG_START_FLOW_ENGINE = 64
|
||||
NUM_REG_FLOG_ENGINE = 4
|
||||
FLOW_ENGINE_VAL_MAX = 15
|
||||
FLOW_ENGINE_VAL_MIN = 0
|
||||
|
||||
# (4 registres : 68 -> 71) Température de consigne de la machine.
|
||||
# Valeur décimale de 30 à 60 = double de la temp de consigne des consignes AC1, AC2, AC3 et AC4
|
||||
REG_START_ORDER_TEMP = 68
|
||||
NUM_REG_ORDER_TEMP = 4
|
||||
|
||||
# (4 registres : 72 -> 75) Programmation des débit des machines du système
|
||||
REG_START_FLOW_STATE_ENGINE = 72
|
||||
NUM_REG_FLOW_STATE_ENGINE = 4
|
||||
|
||||
class FlowEngine(Enum):
|
||||
MANUAL_MIN = 1
|
||||
MANUAL_MED = 2
|
||||
MANUAL_HIGH = 3
|
||||
AUTO = 4
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
# Communication Modbus
|
||||
REG_COMM = 76
|
||||
|
||||
REG_ADDR_MODBUS = 77 # dispo (1 - 127)
|
||||
REG_EFFICIENCY = 78
|
||||
|
||||
# Point d'equilibre entre efficience/vitesse du système de zone
|
||||
# chiffre élevé = meilleur efficience
|
||||
# chiffre bas = temp réglée atteinte au plus tot
|
||||
class Efficiency(Enum):
|
||||
LOWER_EFF = 1
|
||||
LOW_EFF = 2
|
||||
MED_EFF = 3
|
||||
HIGH_EFF = 4
|
||||
HIGHER_EFF = 5
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
REG_CLIM_ID = 79
|
||||
REG_SYS_STATE = 80
|
||||
|
||||
class SysState(Enum):
|
||||
SYS_STATE_OFF = 0
|
||||
SYS_STATE_ON = 1
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
|
||||
REG_GLOBAL_MODE = 81
|
||||
|
||||
class GlobalMode(Enum):
|
||||
COLD = 1
|
||||
HEAT = 2
|
||||
HEATING_FLOOR = 4
|
||||
REFRESHING_FLOOR = 5
|
||||
HEATING_FLOOR_2 = 6
|
||||
|
||||
def __int__(self):
|
||||
return self.value
|
||||
@@ -0,0 +1,697 @@
|
||||
""" local API to manage system, engines and areas """
|
||||
|
||||
import re, sys, os
|
||||
import logging as log
|
||||
import asyncio
|
||||
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from ..const import DOMAIN
|
||||
|
||||
from . import const
|
||||
from .operations import Operations, ModbusConnexionError
|
||||
|
||||
_LOGGER = log.getLogger(__name__)
|
||||
|
||||
class Area:
|
||||
''' koolnova Area class '''
|
||||
|
||||
def __init__(self,
|
||||
name:str = "",
|
||||
id_zone:int = 0,
|
||||
state:const.ZoneState = const.ZoneState.STATE_OFF,
|
||||
register:const.ZoneRegister = const.ZoneRegister.REGISTER_OFF,
|
||||
fan_mode:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF,
|
||||
clim_mode:const.ZoneClimMode = const.ZoneClimMode.OFF,
|
||||
real_temp:float = 0,
|
||||
order_temp:float = 0
|
||||
) -> None:
|
||||
''' Class constructor '''
|
||||
self._name = name
|
||||
self._id = id_zone
|
||||
self._state = state
|
||||
self._register = register
|
||||
self._fan_mode = fan_mode
|
||||
self._clim_mode = clim_mode
|
||||
self._real_temp = real_temp
|
||||
self._order_temp = order_temp
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
''' Get area name '''
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, name:str) -> None:
|
||||
''' Set area name '''
|
||||
if not isinstance(name, str):
|
||||
raise AssertionError('Input variable must be a string')
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def id_zone(self) -> int:
|
||||
''' Get area id '''
|
||||
return self._id
|
||||
|
||||
@property
|
||||
def state(self) -> const.ZoneState:
|
||||
''' Get state '''
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, val:const.ZoneState) -> None:
|
||||
''' Set state '''
|
||||
if not isinstance(val, const.ZoneState):
|
||||
raise AssertionError('Input variable must be Enum ZoneState')
|
||||
self._state = val
|
||||
|
||||
@property
|
||||
def register(self) -> const.ZoneRegister:
|
||||
''' Get register state '''
|
||||
return self._register
|
||||
|
||||
@register.setter
|
||||
def register(self, val:const.ZoneRegister) -> None:
|
||||
''' Set register state '''
|
||||
if not isinstance(val, const.ZoneRegister):
|
||||
raise AssertionError('Input variable must be Enum ZoneRegister')
|
||||
self._register = val
|
||||
|
||||
@property
|
||||
def fan_mode(self) -> const.ZoneFanMode:
|
||||
''' Get Fan Mode '''
|
||||
return self._fan_mode
|
||||
|
||||
@fan_mode.setter
|
||||
def fan_mode(self, val:const.ZoneFanMode) -> None:
|
||||
''' Set Fan Mode '''
|
||||
if not isinstance(val, const.ZoneFanMode):
|
||||
raise AssertionError('Input variable must be Enum ZoneFanMode')
|
||||
self._fan_mode = val
|
||||
|
||||
@property
|
||||
def clim_mode(self) -> const.ZoneClimMode:
|
||||
''' Get Clim Mode '''
|
||||
return self._clim_mode
|
||||
|
||||
@clim_mode.setter
|
||||
def clim_mode(self, val:const.ZoneClimMode) -> None:
|
||||
''' Set Clim Mode '''
|
||||
if not isinstance(val, const.ZoneClimMode):
|
||||
raise AssertionError('Input variable must be Enum ZoneClimMode')
|
||||
self._clim_mode = val
|
||||
|
||||
@property
|
||||
def real_temp(self) -> float:
|
||||
''' Get real temp '''
|
||||
return self._real_temp
|
||||
|
||||
@real_temp.setter
|
||||
def real_temp(self, val:float) -> None:
|
||||
''' Set Real Temp '''
|
||||
if not isinstance(val, float):
|
||||
raise AssertionError('Input variable must be Float')
|
||||
self._real_temp = val
|
||||
|
||||
@property
|
||||
def order_temp(self) -> float:
|
||||
''' Get order temp '''
|
||||
return self._order_temp
|
||||
|
||||
@order_temp.setter
|
||||
def order_temp(self, val:float) -> None:
|
||||
''' Set Order Temp '''
|
||||
if not isinstance(val, float):
|
||||
raise AssertionError('Input variable must be float')
|
||||
if val > const.MAX_TEMP_ORDER or val < const.MIN_TEMP_ORDER:
|
||||
raise OrderTempError('Order temp value must be between {} and {}'.format(const.MIN_TEMP_ORDER, const.MAX_TEMP_ORDER))
|
||||
self._order_temp = val
|
||||
|
||||
def __repr__(self) -> str:
|
||||
''' repr method '''
|
||||
return repr('Area(Name: {}, Id:{}, State:{}, Register:{}, Fan:{}, Clim:{}, Real Temp:{}, Order Temp:{})'.format(
|
||||
self._name,
|
||||
self._id,
|
||||
self._state,
|
||||
self._register,
|
||||
self._fan_mode,
|
||||
self._clim_mode,
|
||||
self._real_temp,
|
||||
self._order_temp))
|
||||
|
||||
class Engine:
|
||||
''' koolnova Engine class '''
|
||||
|
||||
def __init__(self,
|
||||
engine_id:int = 0,
|
||||
throughput:int = 0,
|
||||
state:const.FlowEngine = const.FlowEngine.AUTO,
|
||||
order_temp:float = 0
|
||||
) -> None:
|
||||
''' Constructor class '''
|
||||
self._engine_id = engine_id
|
||||
self._throughput = throughput
|
||||
self._state = state
|
||||
self._order_temp = order_temp
|
||||
|
||||
@property
|
||||
def engine_id(self) -> int:
|
||||
''' Get Engine ID '''
|
||||
return self._engine_id
|
||||
|
||||
@engine_id.setter
|
||||
def engine_id(self, val:int) -> None:
|
||||
''' Set Engine ID '''
|
||||
if not isinstance(val, int):
|
||||
raise AssertionError('Input variable must be Int')
|
||||
if val > const.NUM_OF_ENGINES:
|
||||
raise NumUnitError('Engine ID must be lower than {}'.format(const.NUM_OF_ENGINES))
|
||||
self._engine_id = val
|
||||
|
||||
@property
|
||||
def throughput(self) -> int:
|
||||
''' Get throughput Engine '''
|
||||
return self._throughput
|
||||
|
||||
@throughput.setter
|
||||
def throughput(self, val:int) -> None:
|
||||
''' Set throughput Engine '''
|
||||
if not isinstance(val, int):
|
||||
raise AssertionError('Input variable must be Int')
|
||||
if val > const.FLOW_ENGINE_VAL_MAX or val < const.FLOW_ENGINE_VAL_MIN:
|
||||
raise FlowEngineError('throughput engine value ({}) must be between {} and {}'.format(val,
|
||||
const.FLOW_ENGINE_VAL_MIN,
|
||||
const.FLOW_ENGINE_VAL_MAX))
|
||||
self._throughput = val
|
||||
|
||||
@property
|
||||
def state(self) -> const.FlowEngine:
|
||||
''' Get Engine state '''
|
||||
return self._state
|
||||
|
||||
@state.setter
|
||||
def state(self, val:const.FlowEngine) -> None:
|
||||
''' Set Engine state '''
|
||||
if not isinstance(val, const.FlowEngine):
|
||||
raise AssertionError('Input variable must be Enum FlowEngine')
|
||||
self._state = val
|
||||
|
||||
@property
|
||||
def order_temp(self) -> float:
|
||||
''' Get Order Temp '''
|
||||
return self._order_temp
|
||||
|
||||
@order_temp.setter
|
||||
def order_temp(self, val:float = 0.0) -> None:
|
||||
''' Set order temp engine '''
|
||||
if not isinstance(val, float):
|
||||
raise AssertionError('Input variable must be Int')
|
||||
if val > 0 and (val > 30.0 or val < 15.0):
|
||||
raise OrderTempError('Flow Engine value ({}) must be between 15 and 30'.format(val))
|
||||
self._order_temp = val
|
||||
|
||||
def __repr__(self) -> str:
|
||||
''' repr method '''
|
||||
return repr('Unit(Id:{}, Throughput:{}, State:{}, Order Temp:{})'.format(self._engine_id,
|
||||
self._throughput,
|
||||
self._state,
|
||||
self._order_temp))
|
||||
|
||||
class Koolnova:
|
||||
''' koolnova Device class '''
|
||||
|
||||
def __init__(self,
|
||||
name:str = "",
|
||||
port:str = "",
|
||||
addr:int = const.DEFAULT_ADDR,
|
||||
baudrate:int = const.DEFAULT_BAUDRATE,
|
||||
parity:str = const.DEFAULT_PARITY,
|
||||
bytesize:int = const.DEFAULT_BYTESIZE,
|
||||
stopbits:int = const.DEFAULT_STOPBITS,
|
||||
timeout:int = 1) -> None:
|
||||
''' Class constructor '''
|
||||
self._client = Operations(port=port,
|
||||
addr=addr,
|
||||
baudrate=baudrate,
|
||||
parity=parity,
|
||||
bytesize=bytesize,
|
||||
stopbits=stopbits,
|
||||
timeout=timeout)
|
||||
self._name = name
|
||||
self._global_mode = const.GlobalMode.COLD
|
||||
self._efficiency = const.Efficiency.LOWER_EFF
|
||||
self._sys_state = const.SysState.SYS_STATE_OFF
|
||||
self._engines = []
|
||||
self._areas = []
|
||||
|
||||
def _area_defined(self,
|
||||
id_search:int = 0,
|
||||
) -> (bool, int):
|
||||
""" test if area id is defined """
|
||||
_areas_found = [idx for idx, x in enumerate(self._areas) if x.id_zone == id_search]
|
||||
_idx = 0
|
||||
if not _areas_found:
|
||||
_LOGGER.error("Area id ({}) not defined".format(id_search))
|
||||
return False, _idx
|
||||
elif len(_areas_found) > 1:
|
||||
_LOGGER.error("Multiple Area with same id ({})".format(id_search))
|
||||
return False, _idx
|
||||
else:
|
||||
_idx = _areas_found[0]
|
||||
_LOGGER.debug("idx found: {}".format(_idx))
|
||||
return True, _idx
|
||||
|
||||
async def async_update(self) -> bool:
|
||||
''' update values from modbus '''
|
||||
_LOGGER.debug("Retreive system status ...")
|
||||
ret, self._sys_state = await self._client.async_system_status()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving system status")
|
||||
self._sys_state = const.SysState.SYS_STATE_OFF
|
||||
|
||||
_LOGGER.debug("Retreive global mode ...")
|
||||
ret, self._global_mode = await self._client.async_global_mode()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving global mode")
|
||||
self._global_mode = const.GlobalMode.COLD
|
||||
|
||||
_LOGGER.debug("Retreive efficiency ...")
|
||||
ret, self._efficiency = await self._client.async_efficiency()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving efficiency")
|
||||
self._efficiency = const.Efficiency.LOWER_EFF
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
_LOGGER.debug("Retreive engines ...")
|
||||
for idx in range(1, const.NUM_OF_ENGINES + 1):
|
||||
engine = Engine(engine_id = idx)
|
||||
ret, engine.throughput = await self._client.async_engine_throughput(engine_id = idx)
|
||||
ret, engine.state = await self._client.async_engine_state(engine_id = idx)
|
||||
ret, engine.order_temp = await self._client.async_engine_order_temp(engine_id = idx)
|
||||
self._engines.append(engine)
|
||||
await asyncio.sleep(0.1)
|
||||
return True
|
||||
|
||||
async def async_connect(self) -> bool:
|
||||
''' connect to the modbus serial server '''
|
||||
ret = True
|
||||
await self._client.async_connect()
|
||||
if not self.connected():
|
||||
ret = False
|
||||
raise ClientNotConnectedError("Client Modbus connexion error")
|
||||
|
||||
#_LOGGER.info("Update system values")
|
||||
#ret = await self.update()
|
||||
|
||||
return ret
|
||||
|
||||
def connected(self) -> bool:
|
||||
''' get modbus client status '''
|
||||
return self._client.connected
|
||||
|
||||
def disconnect(self) -> None:
|
||||
''' close the underlying socket connection '''
|
||||
self._client.disconnect()
|
||||
|
||||
async def async_discover_areas(self) -> None:
|
||||
''' Set all registered areas for system '''
|
||||
if not self._client.connected:
|
||||
raise ModbusConnexionError('Client Modbus not connected')
|
||||
zones_lst = await self._client.async_discover_registered_areas()
|
||||
for zone in zones_lst:
|
||||
self._areas.append(Area(name = zone['name'],
|
||||
id_zone = zone['id'],
|
||||
state = zone['state'],
|
||||
register = zone['register'],
|
||||
fan_mode = zone['fan'],
|
||||
clim_mode = zone['clim'],
|
||||
real_temp = zone['real_temp'],
|
||||
order_temp = zone['order_temp']
|
||||
))
|
||||
return
|
||||
|
||||
async def async_add_manual_registered_area(self,
|
||||
name:str = "",
|
||||
id_zone:int = 0) -> bool:
|
||||
''' Add manual area to koolnova system '''
|
||||
if not self._client.connected:
|
||||
raise ModbusConnexionError('Client Modbus not connected')
|
||||
|
||||
ret, zone_dict = await self._client.async_area_registered(zone_id = id_zone)
|
||||
if not ret:
|
||||
_LOGGER.error("Zone with ID: {} is not registered".format(id_zone))
|
||||
return False
|
||||
for zone in self._areas:
|
||||
if id_zone == zone.id_zone:
|
||||
_LOGGER.error('Zone registered with ID: {} is already saved')
|
||||
return False
|
||||
|
||||
self._areas.append(Area(name = name,
|
||||
id_zone = id_zone,
|
||||
state = zone_dict['state'],
|
||||
register = zone_dict['register'],
|
||||
fan_mode = zone_dict['fan'],
|
||||
clim_mode = zone_dict['clim'],
|
||||
real_temp = zone_dict['real_temp'],
|
||||
order_temp = zone_dict['order_temp']
|
||||
))
|
||||
_LOGGER.debug("Areas registered: {}".format(self._areas))
|
||||
return True
|
||||
|
||||
@property
|
||||
def areas(self) -> list:
|
||||
''' get areas '''
|
||||
return self._areas
|
||||
|
||||
def get_area(self, zone_id:int = 0) -> Area:
|
||||
''' get specific area '''
|
||||
return self._areas[zone_id - 1]
|
||||
|
||||
async def async_update_area(self, zone_id:int = 0) -> bool:
|
||||
""" update specific area from zone_id """
|
||||
ret, infos = await self._client.async_area_registered(zone_id = zone_id)
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving area ({}) values".format(zone_id))
|
||||
return ret, None
|
||||
for idx, area in enumerate(self._areas):
|
||||
if area.id_zone == zone_id:
|
||||
# update areas list values from modbus response
|
||||
self._areas[idx].state = infos['state']
|
||||
self._areas[idx].register = infos['register']
|
||||
self._areas[idx].fan_mode = infos['fan']
|
||||
self._areas[idx].clim_mode = infos['clim']
|
||||
self._areas[idx].real_temp = infos['real_temp']
|
||||
self._areas[idx].order_temp = infos['order_temp']
|
||||
break
|
||||
return ret, self._areas[zone_id - 1]
|
||||
|
||||
async def async_update_all_areas(self) -> list:
|
||||
""" update all areas registered and all engines values """
|
||||
##### Areas
|
||||
_ret, _vals = await self._client.async_areas_registered()
|
||||
if not _ret:
|
||||
_LOGGER.error("Error retreiving areas values")
|
||||
return None
|
||||
else:
|
||||
for k,v in _vals.items():
|
||||
for _idx, _area in enumerate(self._areas):
|
||||
if k == _area.id_zone:
|
||||
# update areas list values from modbus response
|
||||
self._areas[_idx].state = v['state']
|
||||
self._areas[_idx].register = v['register']
|
||||
self._areas[_idx].fan_mode = v['fan']
|
||||
self._areas[_idx].clim_mode = v['clim']
|
||||
self._areas[_idx].real_temp = v['real_temp']
|
||||
self._areas[_idx].order_temp = v['order_temp']
|
||||
|
||||
##### Engines
|
||||
for _idx in range(1, const.NUM_OF_ENGINES + 1):
|
||||
ret, self._engines[_idx - 1].throughput = await self._client.async_engine_throughput(engine_id = _idx)
|
||||
ret, self._engines[_idx - 1].state = await self._client.async_engine_state(engine_id = _idx)
|
||||
ret, self._engines[_idx - 1].order_temp = await self._client.async_engine_order_temp(engine_id = _idx)
|
||||
|
||||
##### Global mode
|
||||
ret, self._global_mode = await self._client.async_global_mode()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving global mode")
|
||||
self._global_mode = const.GlobalMode.COLD
|
||||
|
||||
##### Efficiency
|
||||
ret, self._efficiency = await self._client.async_efficiency()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving efficiency")
|
||||
self._efficiency = const.Efficiency.LOWER_EFF
|
||||
|
||||
##### Sys state
|
||||
ret, self._sys_state = await self._client.async_system_status()
|
||||
if not ret:
|
||||
_LOGGER.error("Error retreiving system status")
|
||||
self._sys_state = const.SysState.SYS_STATE_OFF
|
||||
|
||||
return {"areas": self._areas,
|
||||
"engines": self._engines,
|
||||
"glob": self._global_mode,
|
||||
"eff": self._efficiency,
|
||||
"sys": self._sys_state}
|
||||
|
||||
@property
|
||||
def engines(self) -> list:
|
||||
''' get engines '''
|
||||
return self._engines
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
""" Return a device description for device registry """
|
||||
return {
|
||||
"name": self._name,
|
||||
"manufacturer": "Koolnova",
|
||||
"identifiers": {(DOMAIN, "deadbeef")},
|
||||
}
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
''' Get name '''
|
||||
return self._name
|
||||
|
||||
async def async_set_engine_state(self,
|
||||
val:const.FlowEngine,
|
||||
engine_id: int,
|
||||
) -> None:
|
||||
''' set engine flow from id '''
|
||||
_LOGGER.debug("set engine (id:{}) flow : {}".format(engine_id, val))
|
||||
if not isinstance(val, const.FlowEngine):
|
||||
raise AssertionError('Input variable must be Enum FlowEngine')
|
||||
ret = await self._client.async_set_engine_state(engine_id, val)
|
||||
if not ret:
|
||||
_LOGGER.error("[GLOBAL] Error writing {} to modbus".format(val))
|
||||
raise UpdateValueError('Error writing to modbus updated value')
|
||||
self._engines[engine_id - 1].state = val
|
||||
|
||||
@property
|
||||
def global_mode(self) -> const.GlobalMode:
|
||||
''' Get Global Mode '''
|
||||
return self._global_mode
|
||||
|
||||
async def async_set_global_mode(self,
|
||||
val:const.GlobalMode,
|
||||
) -> None:
|
||||
''' Set Global Mode '''
|
||||
_LOGGER.debug("set global mode : {}".format(val))
|
||||
if not isinstance(val, const.GlobalMode):
|
||||
raise AssertionError('Input variable must be Enum GlobalMode')
|
||||
ret = await self._client.async_set_global_mode(val)
|
||||
if not ret:
|
||||
_LOGGER.error("[GLOBAL] Error writing {} to modbus".format(val))
|
||||
raise UpdateValueError('Error writing to modbus updated value')
|
||||
self._global_mode = val
|
||||
|
||||
@property
|
||||
def efficiency(self) -> const.Efficiency:
|
||||
''' Get Efficiency '''
|
||||
return self._efficiency
|
||||
|
||||
async def async_set_efficiency(self,
|
||||
val:const.Efficiency,
|
||||
) -> None:
|
||||
''' Set Efficiency '''
|
||||
_LOGGER.debug("set efficiency : {}".format(val))
|
||||
if not isinstance(val, const.Efficiency):
|
||||
raise AssertionError('Input variable must be Enum Efficiency')
|
||||
ret = await self._client.async_set_efficiency(val)
|
||||
if not ret:
|
||||
_LOGGER.error("[EFF] Error writing {} to modbus".format(val))
|
||||
raise UpdateValueError('Error writing to modbus updated value')
|
||||
self._efficiency = val
|
||||
|
||||
@property
|
||||
def sys_state(self) -> const.SysState:
|
||||
''' Get System State '''
|
||||
return self._sys_state
|
||||
|
||||
async def async_set_sys_state(self,
|
||||
val:const.SysState,
|
||||
) -> None:
|
||||
''' Set System State '''
|
||||
if not isinstance(val, const.SysState):
|
||||
raise AssertionError('Input variable must be Enum SysState')
|
||||
_LOGGER.debug("set system state : {}".format(val))
|
||||
ret = await self._client.async_set_system_status(val)
|
||||
if not ret:
|
||||
_LOGGER.error("[SYS_STATE] Error writing {} to modbus".format(val))
|
||||
raise UpdateValueError('Error writing to modbus updated value')
|
||||
self._sys_state = val
|
||||
|
||||
async def async_get_area_temp(self,
|
||||
zone_id:int,
|
||||
) -> float:
|
||||
""" get current temp of specific Area """
|
||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||
if not _ret:
|
||||
_LOGGER.error("Area not defined ...")
|
||||
return False
|
||||
|
||||
ret, temp = await self._client.async_area_temp(id_zone = zone_id)
|
||||
if not ret:
|
||||
_LOGGER.error("Error reading temp for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
self._areas[_idx].real_temp = temp
|
||||
return temp
|
||||
|
||||
async def async_set_area_target_temp(self,
|
||||
zone_id:int,
|
||||
temp:float,
|
||||
) -> bool:
|
||||
""" set target temp of specific area """
|
||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||
if not _ret:
|
||||
_LOGGER.error("Area not defined ...")
|
||||
return False
|
||||
|
||||
ret = await self._client.async_set_area_target_temp(zone_id = zone_id, val = temp)
|
||||
if not ret:
|
||||
_LOGGER.error("Error writing target temp for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
self._areas[_idx].order_temp = temp
|
||||
return True
|
||||
|
||||
async def async_get_area_target_temp(self,
|
||||
zone_id:int,
|
||||
) -> float:
|
||||
""" get target temp of specific area """
|
||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||
if not _ret:
|
||||
_LOGGER.error("Area not defined ...")
|
||||
return False
|
||||
|
||||
ret, temp = await self._client.async_area_target_temp(id_zone = zone_id)
|
||||
if not ret:
|
||||
_LOGGER.error("Error reading target temp for area with ID: {}".format(zone_id))
|
||||
return 0.0
|
||||
self._areas[_idx].order_temp = temp
|
||||
return temp
|
||||
|
||||
async def async_set_area_clim_mode(self,
|
||||
zone_id:int,
|
||||
mode:const.ZoneClimMode,
|
||||
) -> bool:
|
||||
""" set climate mode for specific area """
|
||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||
if not _ret:
|
||||
_LOGGER.error("Area not defined ...")
|
||||
return False
|
||||
|
||||
if mode == const.ZoneClimMode.OFF:
|
||||
_LOGGER.debug("Set area state to OFF")
|
||||
ret = await self._client.async_set_area_state(id_zone = zone_id, val = const.ZoneState.STATE_OFF)
|
||||
if not ret:
|
||||
_LOGGER.error("Error writing area state for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
self._areas[_idx].state = const.ZoneState.STATE_OFF
|
||||
else:
|
||||
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
|
||||
_LOGGER.debug("Set area state to ON")
|
||||
# update area state
|
||||
ret = await self._client.async_set_area_state(id_zone = zone_id, val = const.ZoneState.STATE_ON)
|
||||
if not ret:
|
||||
_LOGGER.error("Error writing area state for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
_LOGGER.debug("clim mode ? {}".format(mode))
|
||||
# update clim mode
|
||||
ret = await self._client.async_set_area_clim_mode(id_zone = zone_id, val = mode)
|
||||
if not ret:
|
||||
_LOGGER.error("Error writing climate mode for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
self._areas[_idx].clim_mode = mode
|
||||
return True
|
||||
|
||||
async def async_set_area_fan_mode(self,
|
||||
zone_id:int,
|
||||
mode:const.ZoneFanMode,
|
||||
) -> bool:
|
||||
""" set fan mode for specific area """
|
||||
# test if area id is defined
|
||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||
if not _ret:
|
||||
_LOGGER.error("Area not defined ...")
|
||||
return False
|
||||
|
||||
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
|
||||
_LOGGER.warning("Area state is off, cannot change fan speed ...")
|
||||
return False
|
||||
else:
|
||||
_LOGGER.debug("fan mode ? {}".format(mode))
|
||||
# writing new value to modbus
|
||||
ret = await self._client.async_set_area_fan_mode(id_zone = zone_id, val = mode)
|
||||
if not ret:
|
||||
_LOGGER.error("Error writing fan mode for area with ID: {}".format(zone_id))
|
||||
return False
|
||||
# update fan mode in list for specific area
|
||||
self._areas[_idx].fan_mode = mode
|
||||
return True
|
||||
|
||||
def __repr__(self) -> str:
|
||||
''' repr method '''
|
||||
return repr('System(Global Mode:{}, Efficiency:{}, State:{})'.format(
|
||||
self._global_mode,
|
||||
self._efficiency,
|
||||
self._sys_state))
|
||||
|
||||
class NumUnitError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class FlowEngineError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class OrderTempError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class ClientNotConnectedError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class UpdateValueError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
@@ -0,0 +1,543 @@
|
||||
""" local API to communicate with Koolnova BMS Modbus RTU client """
|
||||
|
||||
import re, sys, os
|
||||
import logging as log
|
||||
|
||||
import asyncio
|
||||
|
||||
from pymodbus import pymodbus_apply_logging_config
|
||||
from pymodbus.client import AsyncModbusSerialClient as ModbusClient
|
||||
from pymodbus.exceptions import ModbusException
|
||||
from pymodbus.pdu import ExceptionResponse
|
||||
from pymodbus.transaction import ModbusRtuFramer
|
||||
|
||||
from . import const
|
||||
|
||||
_LOGGER = log.getLogger(__name__)
|
||||
|
||||
class Operations:
|
||||
''' koolnova BMS Modbus operations class '''
|
||||
|
||||
def __init__(self, port:str, timeout:int, debug:bool=False) -> None:
|
||||
''' Class constructor '''
|
||||
self._port = port
|
||||
self._timeout = timeout
|
||||
self._addr = const.DEFAULT_ADDR
|
||||
self._baudrate = const.DEFAULT_BAUDRATE
|
||||
self._parity = const.DEFAULT_PARITY
|
||||
self._bytesize = const.DEFAULT_BYTESIZE
|
||||
self._stopbits = const.DEFAULT_STOPBITS
|
||||
self._client = ModbusClient(port=self._port,
|
||||
baudrate=self._baudrate,
|
||||
parity=self._parity,
|
||||
stopbits=self._stopbits,
|
||||
bytesize=self._bytesize,
|
||||
timeout=self._timeout)
|
||||
if debug:
|
||||
pymodbus_apply_logging_config("DEBUG")
|
||||
|
||||
def __init__(self,
|
||||
port:str="",
|
||||
addr:int=const.DEFAULT_ADDR,
|
||||
baudrate:int=const.DEFAULT_BAUDRATE,
|
||||
parity:str=const.DEFAULT_PARITY,
|
||||
stopbits:int=const.DEFAULT_STOPBITS,
|
||||
bytesize:int=const.DEFAULT_BYTESIZE,
|
||||
timeout:int=1,
|
||||
debug:bool=False) -> None:
|
||||
''' Class constructor '''
|
||||
self._port = port
|
||||
self._addr = addr
|
||||
self._timeout = timeout
|
||||
self._baudrate = baudrate
|
||||
self._parity = parity
|
||||
self._bytesize = bytesize
|
||||
self._stopbits = stopbits
|
||||
self._client = ModbusClient(port=self._port,
|
||||
baudrate=self._baudrate,
|
||||
parity=self._parity,
|
||||
stopbits=self._stopbits,
|
||||
bytesize=self._bytesize,
|
||||
timeout=self._timeout)
|
||||
if debug:
|
||||
pymodbus_apply_logging_config("DEBUG")
|
||||
|
||||
async def __async_read_register(self, reg:int) -> (int, bool):
|
||||
''' Read one holding register (code 0x03) '''
|
||||
rr = None
|
||||
if not self._client.connected:
|
||||
raise ModbusConnexionError('Client Modbus not connected')
|
||||
try:
|
||||
_LOGGER.debug("reading holding register: {} - Addr: {}".format(hex(reg), self._addr))
|
||||
rr = await self._client.read_holding_registers(address=reg, count=1, slave=self._addr)
|
||||
if rr.isError():
|
||||
_LOGGER.error("reading holding register error")
|
||||
return None, False
|
||||
except Exception as e:
|
||||
_LOGGER.error("Modbus Error: {}".format(e))
|
||||
return None, False
|
||||
|
||||
if isinstance(rr, ExceptionResponse):
|
||||
_LOGGER.error("Received modbus exception ({})".format(rr))
|
||||
return None, False
|
||||
elif not rr:
|
||||
_LOGGER.error("Response Null")
|
||||
return None, False
|
||||
return rr.registers[0], True
|
||||
|
||||
async def __async_read_registers(self, start_reg:int, count:int) -> (int, bool):
|
||||
''' Read holding registers (code 0x03) '''
|
||||
rr = None
|
||||
if not self._client.connected:
|
||||
raise ModbusConnexionError('Client Modbus not connected')
|
||||
try:
|
||||
rr = await self._client.read_holding_registers(address=start_reg, count=count, slave=self._addr)
|
||||
if rr.isError():
|
||||
_LOGGER.error("reading holding registers error")
|
||||
return None, False
|
||||
except Exception as e:
|
||||
_LOGGER.error("{}".format(e))
|
||||
return None, False
|
||||
|
||||
if isinstance(rr, ExceptionResponse):
|
||||
_LOGGER.error("Received modbus exception ({})".format(rr))
|
||||
return None, False
|
||||
elif not rr:
|
||||
_LOGGER.error("Response Null")
|
||||
return None, False
|
||||
return rr.registers, True
|
||||
|
||||
async def __async_write_register(self, reg:int, val:int) -> bool:
|
||||
''' Write one register (code 0x06) '''
|
||||
rq = None
|
||||
ret = True
|
||||
if not self._client.connected:
|
||||
raise ModbusConnexionError('Client Modbus not connected')
|
||||
try:
|
||||
_LOGGER.debug("writing single register: {} - Addr: {} - Val: {}".format(hex(reg), self._addr, hex(val)))
|
||||
rq = await self._client.write_register(address=reg, value=val, slave=self._addr)
|
||||
if rq.isError():
|
||||
_LOGGER.error("writing register error")
|
||||
return False
|
||||
except Exception as e:
|
||||
_LOGGER.error("{}".format(e))
|
||||
return False
|
||||
|
||||
if isinstance(rq, ExceptionResponse):
|
||||
_LOGGER.error("Received modbus exception ({})".format(rr))
|
||||
return False
|
||||
return ret
|
||||
|
||||
async def async_connect(self) -> None:
|
||||
''' connect to the modbus serial server '''
|
||||
await self._client.connect()
|
||||
|
||||
def connected(self) -> bool:
|
||||
''' get modbus client status '''
|
||||
return self._client.connected
|
||||
|
||||
def disconnect(self) -> None:
|
||||
''' close the underlying socket connection '''
|
||||
if self._client.connected:
|
||||
self._client.close()
|
||||
|
||||
async def async_discover_registered_areas(self) -> list:
|
||||
''' Discover all areas registered to the system '''
|
||||
regs, ret = await self.__async_read_registers(start_reg=const.REG_START_ZONE,
|
||||
count=const.NB_ZONE_MAX * const.NUM_REG_PER_ZONE)
|
||||
if not ret:
|
||||
raise ReadRegistersError("Read holding regsiter error")
|
||||
zones_lst = []
|
||||
zone_dict = {}
|
||||
jdx = 1
|
||||
flag = False
|
||||
for idx, reg in enumerate(regs):
|
||||
if idx % const.NUM_REG_PER_ZONE == 0:
|
||||
zone_dict = {}
|
||||
if const.ZoneRegister(reg >> 1) == const.ZoneRegister.REGISTER_ON:
|
||||
zone_dict['id'] = jdx
|
||||
zone_dict['state'] = const.ZoneState(reg & 0b01)
|
||||
zone_dict['register'] = const.ZoneRegister(reg >> 1)
|
||||
flag = True
|
||||
elif idx % const.NUM_REG_PER_ZONE == 1 and flag:
|
||||
zone_dict['fan'] = const.ZoneFanMode((reg & 0xF0) >> 4)
|
||||
zone_dict['clim'] = const.ZoneClimMode(reg & 0x0F)
|
||||
elif idx % const.NUM_REG_PER_ZONE == 2 and flag:
|
||||
zone_dict['order_temp'] = reg/2
|
||||
elif idx % const.NUM_REG_PER_ZONE == 3 and flag:
|
||||
zone_dict['real_temp'] = reg/2
|
||||
jdx += 1
|
||||
flag = False
|
||||
zones_lst.append(zone_dict)
|
||||
return zones_lst
|
||||
|
||||
async def async_area_registered(self,
|
||||
zone_id:int = 0,
|
||||
) -> (bool, dict):
|
||||
''' Get Area status from id '''
|
||||
#_LOGGER.debug("Area : {}".format(zone_id))
|
||||
if zone_id > const.NB_ZONE_MAX or zone_id == 0:
|
||||
raise ZoneIdError('Zone Id must be between 1 to {}'.format(const.NB_ZONE_MAX))
|
||||
zone_dict = {}
|
||||
regs, ret = await self.__async_read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)),
|
||||
count = const.NUM_REG_PER_ZONE)
|
||||
if not ret:
|
||||
raise ReadRegistersError("Error reading holding register")
|
||||
if const.ZoneRegister(regs[0] >> 1) == const.ZoneRegister.REGISTER_OFF:
|
||||
_LOGGER.warning("Zone with id: {} is not registered".format(zone_id))
|
||||
return False, {}
|
||||
|
||||
zone_dict['state'] = const.ZoneState(regs[0] & 0b01)
|
||||
zone_dict['register'] = const.ZoneRegister(regs[0] >> 1)
|
||||
zone_dict['fan'] = const.ZoneFanMode((regs[1] & 0xF0) >> 4)
|
||||
zone_dict['clim'] = const.ZoneClimMode(regs[1] & 0x0F)
|
||||
zone_dict['order_temp'] = regs[2]/2
|
||||
zone_dict['real_temp'] = regs[3]/2
|
||||
return True, zone_dict
|
||||
|
||||
async def async_areas_registered(self) -> (bool, dict):
|
||||
""" Get all areas values """
|
||||
_areas_dict:dict = {}
|
||||
# retreive all areas (registered and unregistered)
|
||||
regs, ret = await self.__async_read_registers(start_reg = const.REG_START_ZONE,
|
||||
count = const.NUM_REG_PER_ZONE * const.NB_ZONE_MAX)
|
||||
if not ret:
|
||||
raise ReadRegistersError("Error reading holding register")
|
||||
for area_idx in range(const.NB_ZONE_MAX):
|
||||
_idx:int = 4 * area_idx
|
||||
_area_dict:dict = {}
|
||||
# test if area is registered or not
|
||||
if const.ZoneRegister(regs[_idx + const.REG_LOCK_ZONE] >> 1) == const.ZoneRegister.REGISTER_OFF:
|
||||
continue
|
||||
|
||||
_area_dict['state'] = const.ZoneState(regs[_idx + const.REG_LOCK_ZONE] & 0b01)
|
||||
_area_dict['register'] = const.ZoneRegister(regs[_idx + const.REG_LOCK_ZONE] >> 1)
|
||||
_area_dict['fan'] = const.ZoneFanMode((regs[_idx + const.REG_STATE_AND_FLOW] & 0xF0) >> 4)
|
||||
_area_dict['clim'] = const.ZoneClimMode(regs[_idx + const.REG_STATE_AND_FLOW] & 0x0F)
|
||||
_area_dict['order_temp'] = regs[_idx + const.REG_TEMP_ORDER]/2
|
||||
_area_dict['real_temp'] = regs[_idx + const.REG_TEMP_REAL]/2
|
||||
_areas_dict[area_idx + 1] = _area_dict
|
||||
return True, _areas_dict
|
||||
|
||||
async def async_system_status(self) -> (bool, const.SysState):
|
||||
''' Read system status register '''
|
||||
reg, ret = await self.__async_read_register(const.REG_SYS_STATE)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive system status')
|
||||
reg = 0
|
||||
return ret, const.SysState(reg)
|
||||
|
||||
async def async_set_system_status(self,
|
||||
opt:const.SysState,
|
||||
) -> bool:
|
||||
''' Write system status '''
|
||||
ret = await self.__async_write_register(reg = const.REG_SYS_STATE, val = int(opt))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing system status')
|
||||
return ret
|
||||
|
||||
async def async_global_mode(self) -> (bool, const.GlobalMode):
|
||||
''' Read global mode '''
|
||||
reg, ret = await self.__async_read_register(const.REG_GLOBAL_MODE)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive global mode')
|
||||
reg = 0
|
||||
return ret, const.GlobalMode(reg)
|
||||
|
||||
async def async_set_global_mode(self,
|
||||
opt:const.GlobalMode,
|
||||
) -> bool:
|
||||
''' Write global mode '''
|
||||
ret = await self.__async_write_register(reg = const.REG_GLOBAL_MODE, val = int(opt))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing global mode')
|
||||
return ret
|
||||
|
||||
async def async_efficiency(self) -> (bool, const.Efficiency):
|
||||
''' read efficiency/speed '''
|
||||
reg, ret = await self.__async_read_register(const.REG_EFFICIENCY)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive efficiency')
|
||||
reg = 0
|
||||
return ret, const.Efficiency(reg)
|
||||
|
||||
async def async_set_efficiency(self,
|
||||
opt:const.GlobalMode,
|
||||
) -> bool:
|
||||
''' Write efficiency '''
|
||||
ret = await self.__async_write_register(reg = const.REG_EFFICIENCY, val = int(opt))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing efficiency')
|
||||
return ret
|
||||
|
||||
async def async_engines_throughput(self) -> (bool, list):
|
||||
''' read engines throughput AC1, AC2, AC3, AC4 '''
|
||||
engines_lst = []
|
||||
regs, ret = await self.__async_read_registers(const.REG_START_FLOW_ENGINE,
|
||||
const.NUM_OF_ENGINES)
|
||||
if ret:
|
||||
for idx, reg in enumerate(regs):
|
||||
engines_lst.append(const.FlowEngine(reg))
|
||||
else:
|
||||
_LOGGER.error('Error retreive engines throughput')
|
||||
return ret, engines_lst
|
||||
|
||||
async def async_engine_throughput(self,
|
||||
engine_id:int = 0,
|
||||
) -> (bool, int):
|
||||
''' read engine throughput specified by id '''
|
||||
if engine_id < 1 or engine_id > 4:
|
||||
raise UnitIdError("engine Id must be between 1 and 4")
|
||||
reg, ret = await self.__async_read_register(const.REG_START_FLOW_ENGINE + (engine_id - 1))
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive engine throughput for id:{}'.format(engine_id))
|
||||
reg = 0
|
||||
return ret, reg
|
||||
|
||||
async def async_engine_state(self,
|
||||
engine_id:int = 0,
|
||||
) -> (bool, const.FlowEngine):
|
||||
''' read engine state specified by id '''
|
||||
if engine_id < 1 or engine_id > 4:
|
||||
raise UnitIdError("Engine id must be between 1 and 4")
|
||||
reg, ret = await self.__async_read_register(const.REG_START_FLOW_STATE_ENGINE + (engine_id - 1))
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive engine state for id:{}'.format(engine_id))
|
||||
reg = 0
|
||||
return ret, const.FlowEngine(reg)
|
||||
|
||||
async def async_set_engine_state(self,
|
||||
engine_id:int = 0,
|
||||
opt:const.FlowEngine = const.FlowEngine.AUTO,
|
||||
) -> bool:
|
||||
''' write engine state specified by id '''
|
||||
if engine_id < 1 or engine_id > 4:
|
||||
raise UnitIdError("Engine id must be between 1 and 4")
|
||||
reg, ret = await self.__async_write_register(reg = const.REG_START_FLOW_STATE_ENGINE + (engine_id - 1), val = int(opt))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing engine state for id:{}'.format(engine_id))
|
||||
return ret
|
||||
|
||||
async def async_engine_order_temp(self,
|
||||
engine_id:int = 0,
|
||||
) -> (bool, float):
|
||||
''' read engine order temperature specified by id '''
|
||||
if engine_id < 1 or engine_id > 4:
|
||||
raise UnitIdError("Engine id must be between 1 and 4")
|
||||
reg, ret = await self.__async_read_register(const.REG_START_ORDER_TEMP + (engine_id - 1))
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive engine order temp for id:{}'.format(engine_id))
|
||||
reg = 0
|
||||
return ret, reg / 2
|
||||
|
||||
async def async_engine_orders_temp(self) -> (bool, list):
|
||||
''' read orders temperature for engines : AC1, AC2, AC3, AC4 '''
|
||||
engines_lst = []
|
||||
regs, ret = await self.__async_read_registers(const.REG_START_ORDER_TEMP, const.NUM_OF_ENGINES)
|
||||
if ret:
|
||||
for idx, reg in enumerate(regs):
|
||||
engines_lst.append(reg/2)
|
||||
else:
|
||||
_LOGGER.error('error reading engines order temp registers')
|
||||
return ret, engines_lst
|
||||
|
||||
async def async_set_area_target_temp(self,
|
||||
zone_id:int = 0,
|
||||
val:float = 0.0,
|
||||
) -> bool:
|
||||
''' Set area target temperature '''
|
||||
if zone_id > const.NB_ZONE_MAX or zone_id == 0:
|
||||
raise ZoneIdError('Zone Id must be between 1 to 16')
|
||||
if val > const.MAX_TEMP_ORDER or val < const.MIN_TEMP_ORDER:
|
||||
_LOGGER.error('Order Temperature must be between {} and {}'.format(const.MIN_TEMP_ORDER, const.MAX_TEMP_ORDER))
|
||||
return False
|
||||
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (zone_id - 1)) + const.REG_TEMP_ORDER, val = int(val * 2))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing area order temperature')
|
||||
|
||||
return ret
|
||||
|
||||
async def async_area_temp(self,
|
||||
id_zone:int = 0,
|
||||
) -> (bool, float):
|
||||
""" get temperature of specific area id """
|
||||
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_TEMP_REAL)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive area real temp')
|
||||
reg = 0
|
||||
return ret, reg / 2
|
||||
|
||||
async def async_area_target_temp(self,
|
||||
id_zone:int = 0,
|
||||
) -> (bool, float):
|
||||
""" get target temperature of specific area id """
|
||||
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_TEMP_ORDER)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive area target temp')
|
||||
reg = 0
|
||||
return ret, reg / 2
|
||||
|
||||
async def async_area_clim_and_fan_mode(self,
|
||||
id_zone:int = 0,
|
||||
) -> (bool, const.ZoneFanMode, const.ZoneClimMode):
|
||||
""" get climate and fan mode of specific area id """
|
||||
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive area fan and climate values')
|
||||
reg = 0
|
||||
return ret, const.ZoneFanMode((reg & 0xF0) >> 4), const.ZoneClimMode(reg & 0x0F)
|
||||
|
||||
async def async_area_state_and_register(self,
|
||||
id_zone:int = 0,
|
||||
) -> (bool, const.ZoneRegister, const.ZoneState):
|
||||
""" get area state and register """
|
||||
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_LOCK_ZONE)
|
||||
if not ret:
|
||||
_LOGGER.error('Error retreive area register value')
|
||||
reg = 0
|
||||
return ret, const.ZoneRegister(reg >> 1), const.ZoneState(reg & 0b01)
|
||||
|
||||
async def async_set_area_state(self,
|
||||
id_zone:int = 0,
|
||||
val:const.ZoneState = const.ZoneState.STATE_OFF,
|
||||
) -> bool:
|
||||
""" set area state """
|
||||
register:const.ZoneRegister = const.ZoneRegister.REGISTER_OFF
|
||||
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
|
||||
raise ZoneIdError('Area Id must be between 1 to 16')
|
||||
# retreive values to combine the new state with register read
|
||||
ret, register, _ = await self.async_area_state_and_register(id_zone = id_zone)
|
||||
if not ret:
|
||||
_LOGGER.error("Error reading state and register mode")
|
||||
return ret
|
||||
#_LOGGER.debug("register & state: {}".format(hex((int(register) << 1) | (int(val) & 0b01))))
|
||||
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_LOCK_ZONE,
|
||||
val = int(int(register) << 1) | (int(val) & 0b01))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing area state value')
|
||||
|
||||
return True
|
||||
|
||||
async def async_set_area_clim_mode(self,
|
||||
id_zone:int = 0,
|
||||
val:const.ZoneClimMode = const.ZoneClimMode.OFF,
|
||||
) -> bool:
|
||||
""" set area clim mode """
|
||||
fan:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF
|
||||
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
|
||||
raise ZoneIdError('Zone Id must be between 1 to 16')
|
||||
# retreive values to combine the new climate mode with fan mode read
|
||||
ret, fan, _ = await self.async_area_clim_and_fan_mode(id_zone = id_zone)
|
||||
if not ret:
|
||||
_LOGGER.error("Error reading fan and clim mode")
|
||||
return ret
|
||||
#_LOGGER.debug("Fan & Clim: {}".format(hex((int(fan) << 4) | (int(val) & 0x0F))))
|
||||
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW,
|
||||
val = int(int(fan) << 4) | (int(val) & 0x0F))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing area climate mode')
|
||||
|
||||
return ret
|
||||
|
||||
async def async_set_area_fan_mode(self,
|
||||
id_zone:int = 0,
|
||||
val:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF,
|
||||
) -> bool:
|
||||
""" set area fan mode """
|
||||
clim:const.ZoneClimMode = const.ZoneClimMode.OFF
|
||||
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
|
||||
raise ZoneIdError('Zone Id must be between 1 to 16')
|
||||
# retreive values to combine the new fan mode with climate mode read
|
||||
ret, _, clim = await self.async_area_clim_and_fan_mode(id_zone = id_zone)
|
||||
if not ret:
|
||||
_LOGGER.error("Error reading fan and clim mode")
|
||||
return ret
|
||||
#_LOGGER.debug("Fan & Clim: {}".format(hex((int(val) << 4) | (int(clim) & 0x0F))))
|
||||
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW,
|
||||
val = int(int(val) << 4) | (int(clim) & 0x0F))
|
||||
if not ret:
|
||||
_LOGGER.error('Error writing area fan mode')
|
||||
return ret
|
||||
|
||||
@property
|
||||
def port(self) -> str:
|
||||
''' Get Port '''
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def address(self) -> str:
|
||||
''' Get address '''
|
||||
return self._addr
|
||||
|
||||
@property
|
||||
def baudrate(self) -> str:
|
||||
''' Get baudrate '''
|
||||
return self._baudrate
|
||||
|
||||
@property
|
||||
def parity(self) -> str:
|
||||
''' Get parity '''
|
||||
return self._parity
|
||||
|
||||
@property
|
||||
def bytesize(self) -> str:
|
||||
''' Get bytesize '''
|
||||
return self._bytesize
|
||||
|
||||
@property
|
||||
def stopbits(self) -> str:
|
||||
''' Get stopbits '''
|
||||
return self._stopbits
|
||||
|
||||
@property
|
||||
def timeout(self) -> int:
|
||||
''' Get Timeout '''
|
||||
return self._timeout
|
||||
|
||||
class ModbusConnexionError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class ReadRegistersError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class ZoneIdError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
|
||||
class UnitIdError(Exception):
|
||||
''' user defined exception '''
|
||||
|
||||
def __init__(self,
|
||||
msg:str = "") -> None:
|
||||
''' Class Constructor '''
|
||||
self._msg = msg
|
||||
|
||||
def __str__(self):
|
||||
''' print the message '''
|
||||
return self._msg
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"domain": "koolnova_bms",
|
||||
"name": "Koolnova BMS Modbus RS485",
|
||||
"codeowners": ["@sinseman44"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://github.com/sinseman44/koolnova-BMS-Integration",
|
||||
"issue_tracker": "https://github.com/sinseman44/koolnova-BMS-Integration/issues",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"loggers": ["koolnova"],
|
||||
"requirements": [
|
||||
"pymodbus==3.5.4",
|
||||
"pyserial==3.5"
|
||||
],
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
""" for select component """
|
||||
# pylint: disable = too-few-public-methods
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback, Event, State
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.const import UnitOfTime
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
GLOBAL_MODES,
|
||||
GLOBAL_MODE_TRANSLATION,
|
||||
EFF_MODES,
|
||||
EFF_TRANSLATION,
|
||||
ENGINE_FLOW_MODES,
|
||||
ENGINE_FLOW_TRANSLATION,
|
||||
)
|
||||
|
||||
from .coordinator import KoolnovaCoordinator
|
||||
|
||||
from .koolnova.device import (
|
||||
Koolnova,
|
||||
Engine,
|
||||
)
|
||||
|
||||
from .koolnova.const import (
|
||||
GlobalMode,
|
||||
Efficiency,
|
||||
FlowEngine,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
):
|
||||
""" Setup select entries """
|
||||
|
||||
device = hass.data[DOMAIN]["device"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
entities = [
|
||||
GlobalModeSelect(coordinator, device),
|
||||
EfficiencySelect(coordinator, device),
|
||||
]
|
||||
|
||||
for engine in device.engines:
|
||||
entities.append(EngineStateSelect(coordinator, device, engine))
|
||||
async_add_entities(entities)
|
||||
|
||||
class GlobalModeSelect(CoordinatorEntity, SelectEntity):
|
||||
""" Select component to set global HVAC mode """
|
||||
|
||||
_attr_entity_category: EntityCategory = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_options = GLOBAL_MODES
|
||||
self._device = device
|
||||
self._attr_name = f"{device.name} Global HVAC Mode"
|
||||
self._attr_device_info = device.device_info
|
||||
self._attr_icon = "mdi:cog-clockwise"
|
||||
self._attr_unique_id = f"{DOMAIN}-Global-HVACMode-select"
|
||||
self.__select_option(
|
||||
GLOBAL_MODE_TRANSLATION[int(self._device.global_mode)]
|
||||
)
|
||||
|
||||
def __select_option(self, option: str) -> None:
|
||||
""" Change the selected option. """
|
||||
self._attr_current_option = option
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
""" Change the selected option. """
|
||||
opt = 0
|
||||
for k,v in GLOBAL_MODE_TRANSLATION.items():
|
||||
if v == option:
|
||||
opt = k
|
||||
break
|
||||
await self._device.async_set_global_mode(GlobalMode(opt))
|
||||
self.__select_option(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator
|
||||
Retrieve latest state of global mode """
|
||||
_LOGGER.debug("[UPDATE] Global Mode: {}".format(self.coordinator.data['glob']))
|
||||
self.__select_option(
|
||||
GLOBAL_MODE_TRANSLATION[int(self.coordinator.data['glob'])]
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
class EfficiencySelect(CoordinatorEntity, SelectEntity):
|
||||
"""Select component to set global efficiency """
|
||||
|
||||
_attr_entity_category: EntityCategory = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument,
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_options = EFF_MODES
|
||||
self._device = device
|
||||
self._attr_name = f"{device.name} Global HVAC Efficiency"
|
||||
self._attr_device_info = device.device_info
|
||||
self._attr_icon = "mdi:wind-power-outline"
|
||||
self._attr_unique_id = f"{DOMAIN}-Global-HVACEff-select"
|
||||
self.__select_option(
|
||||
EFF_TRANSLATION[int(self._device.efficiency)]
|
||||
)
|
||||
|
||||
def __select_option(self,
|
||||
option: str,
|
||||
) -> None:
|
||||
""" Change the selected option. """
|
||||
self._attr_current_option = option
|
||||
|
||||
async def async_select_option(self,
|
||||
option: str,
|
||||
) -> None:
|
||||
""" Change the selected option. """
|
||||
_LOGGER.debug("[EFF] async_select_option: {}".format(option))
|
||||
opt = 0
|
||||
for k,v in EFF_TRANSLATION.items():
|
||||
if v == option:
|
||||
opt = k
|
||||
break
|
||||
await self._device.async_set_efficiency(Efficiency(opt))
|
||||
self.__select_option(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator
|
||||
Retrieve latest state of global efficiency """
|
||||
_LOGGER.debug("[UPDATE] Efficiency: {}".format(self.coordinator.data['eff']))
|
||||
self.__select_option(
|
||||
EFF_TRANSLATION[int(self.coordinator.data['eff'])]
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
class EngineStateSelect(CoordinatorEntity, SelectEntity):
|
||||
"""Select component to set flow engine """
|
||||
|
||||
_attr_entity_category: EntityCategory = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument,
|
||||
engine: Engine, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_options = ENGINE_FLOW_MODES
|
||||
self._device = device
|
||||
self._engine = engine
|
||||
self._attr_name = f"{device.name} Engine AC{engine.engine_id} State"
|
||||
self._attr_device_info = device.device_info
|
||||
self._attr_icon = "mdi:turbine"
|
||||
self._attr_unique_id = f"{DOMAIN}-Engine-AC{engine.engine_id}-State-select"
|
||||
self.__select_option(
|
||||
ENGINE_FLOW_TRANSLATION[int(self._engine.state)]
|
||||
)
|
||||
|
||||
def __select_option(self,
|
||||
option: str,
|
||||
) -> None:
|
||||
""" Change the selected option. """
|
||||
self._attr_current_option = option
|
||||
|
||||
async def async_select_option(self,
|
||||
option: str,
|
||||
) -> None:
|
||||
""" Change the selected option. """
|
||||
_LOGGER.debug("[ENGINE FLOW] async_select_option: {}".format(option))
|
||||
opt = 0
|
||||
for k,v in ENGINE_FLOW_TRANSLATION.items():
|
||||
if v == option:
|
||||
opt = k
|
||||
break
|
||||
await self._device.async_set_engine_state(FlowEngine(opt), self._engine.engine_id)
|
||||
self.__select_option(option)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator
|
||||
Retrieve latest state of global efficiency """
|
||||
for _cur_engine in self.coordinator.data['engines']:
|
||||
if self._engine.engine_id == _cur_engine.engine_id:
|
||||
_LOGGER.debug("[UPDATE] [ENGINE AC{}] Order temp: {}".format(_cur_engine.engine_id, _cur_engine.state))
|
||||
self.__select_option(
|
||||
ENGINE_FLOW_TRANSLATION[int(_cur_engine.state)]
|
||||
)
|
||||
break
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,188 @@
|
||||
""" for sensors components """
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback, Event, State
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from homeassistant.helpers.event import (
|
||||
async_track_time_interval,
|
||||
async_track_state_change_event,
|
||||
)
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_TEMPERATURE,
|
||||
UnitOfTime,
|
||||
UnitOfTemperature
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN
|
||||
)
|
||||
|
||||
from .coordinator import KoolnovaCoordinator
|
||||
|
||||
from .koolnova.device import (
|
||||
Koolnova,
|
||||
Engine,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback):
|
||||
""" Configuration des entités sensor à partir de la configuration
|
||||
ConfigEntry passée en argument
|
||||
"""
|
||||
|
||||
device = hass.data[DOMAIN]["device"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
entities = [
|
||||
DiagnosticsSensor(device, "Device", entry.data),
|
||||
DiagnosticsSensor(device, "Address", entry.data),
|
||||
DiagModbusSensor(device, entry.data),
|
||||
]
|
||||
for engine in device.engines:
|
||||
entities.append(DiagEngineThroughputSensor(coordinator, device, engine))
|
||||
entities.append(DiagEngineTempOrderSensor(coordinator, device, engine))
|
||||
async_add_entities(entities)
|
||||
|
||||
class DiagnosticsSensor(SensorEntity):
|
||||
# pylint: disable = too-many-instance-attributes
|
||||
""" Representation of a Sensor """
|
||||
|
||||
_attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self,
|
||||
device: Koolnova, # pylint: disable=unused-argument,
|
||||
name:str, # pylint: disable=unused-argument
|
||||
entry_infos, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
self._device = device
|
||||
self._attr_name = f"{device.name} {name}"
|
||||
self._attr_entity_registry_enabled_default = True
|
||||
self._attr_device_info = self._device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-{name}-sensor"
|
||||
self._attr_native_value = entry_infos.get(name)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return "mdi:monitor"
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
""" Do not poll for those entities """
|
||||
return False
|
||||
|
||||
class DiagModbusSensor(SensorEntity):
|
||||
# pylint: disable = too-many-instance-attributes
|
||||
""" Representation of a Sensor """
|
||||
|
||||
_attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self,
|
||||
device: Koolnova, # pylint: disable=unused-argument,
|
||||
entry_infos, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
self._device = device
|
||||
self._attr_name = f"{device.name} Modbus RTU"
|
||||
self._attr_entity_registry_enabled_default = True
|
||||
self._attr_device_info = self._device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-Modbus-RTU-sensor"
|
||||
self._attr_native_value = "{} {}{}{}".format(entry_infos.get("Baudrate"),
|
||||
entry_infos.get("Sizebyte"),
|
||||
entry_infos.get("Parity")[0],
|
||||
entry_infos.get("Stopbits"))
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return "mdi:monitor"
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
""" Do not poll for those entities """
|
||||
return False
|
||||
|
||||
class DiagEngineThroughputSensor(CoordinatorEntity, SensorEntity):
|
||||
# pylint: disable = too-many-instance-attributes
|
||||
""" Representation of a Sensor """
|
||||
|
||||
_attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument
|
||||
engine: Engine, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._engine = engine
|
||||
self._attr_name = f"{device.name} Engine AC{engine.engine_id} Throughput"
|
||||
self._attr_entity_registry_enabled_default = True
|
||||
self._attr_device_info = self._device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-Engine-AC{engine.engine_id}-throughput-sensor"
|
||||
self._attr_native_value = "{}".format(engine.throughput)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return "mdi:thermostat-cog"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator """
|
||||
for _cur_engine in self.coordinator.data['engines']:
|
||||
if self._engine.engine_id == _cur_engine.engine_id:
|
||||
_LOGGER.debug("[UPDATE] [ENGINE AC{}] Troughput: {}".format(_cur_engine.engine_id, _cur_engine.throughput))
|
||||
self._attr_native_value = "{}".format(_cur_engine.throughput)
|
||||
self.async_write_ha_state()
|
||||
|
||||
class DiagEngineTempOrderSensor(CoordinatorEntity, SensorEntity):
|
||||
# pylint: disable = too-many-instance-attributes
|
||||
""" Representation of a Sensor """
|
||||
|
||||
_attr_entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument
|
||||
engine: Engine, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
""" Class constructor """
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._engine = engine
|
||||
self._attr_name = f"{device.name} Engine AC{engine.engine_id} Temperature Order"
|
||||
self._attr_entity_registry_enabled_default = True
|
||||
self._attr_device_info = self._device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-Engine-AC{engine.engine_id}-temp-order-sensor"
|
||||
self._attr_native_value = "{}".format(engine.order_temp)
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
return "mdi:thermometer-lines"
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator """
|
||||
for _cur_engine in self.coordinator.data['engines']:
|
||||
if self._engine.engine_id == _cur_engine.engine_id:
|
||||
_LOGGER.debug("[UPDATE] [ENGINE AC{}] Order temp: {}".format(_cur_engine.engine_id, _cur_engine.order_temp))
|
||||
self._attr_native_value = "{}".format(_cur_engine.order_temp)
|
||||
self.async_write_ha_state()
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"title": "testVBE_4",
|
||||
"config": {
|
||||
"flow_title": "Test VBE 4 configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configuration Client Modbus RTU",
|
||||
"description": "Informations de connexion sur le système Koolnova",
|
||||
"data": {
|
||||
"Name": "Name",
|
||||
"Device": "Device",
|
||||
"Address": "Address",
|
||||
"Baudrate": "Baudrate",
|
||||
"Sizebyte": "Sizebyte",
|
||||
"Parity": "Parity",
|
||||
"Stopbits": "Stopbits",
|
||||
"Timeout": "Timeout",
|
||||
"Debug": "Debug"
|
||||
}
|
||||
},
|
||||
"areas": {
|
||||
"title": "Configuration d'une zone",
|
||||
"description": "Information sur la zone à configurer",
|
||||
"data": {
|
||||
"Name": "Name",
|
||||
"Area_id": "Identifiant de la zone",
|
||||
"Other_area": "Ajouter une nouvelle zone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connected to Koolnova system",
|
||||
"area_not_registered": "This Area is not registered to the Koolnova system",
|
||||
"area_already_configured": "This Area is already configured",
|
||||
"zone_id_error": "Area Id must an integer between 1 and 16"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
""" for switch component """
|
||||
# pylint: disable = too-few-public-methods
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback, Event, State
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.helpers.entity import EntityCategory
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.components.switch import (
|
||||
SwitchEntity,
|
||||
SwitchDeviceClass
|
||||
)
|
||||
|
||||
from homeassistant.util import Throttle
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
CoordinatorEntity,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN
|
||||
)
|
||||
|
||||
from .coordinator import KoolnovaCoordinator
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_OFF,
|
||||
STATE_ON
|
||||
)
|
||||
|
||||
from .koolnova.device import Koolnova
|
||||
from .koolnova.const import (
|
||||
SysState,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
):
|
||||
""" Setup switch entries """
|
||||
|
||||
device = hass.data[DOMAIN]["device"]
|
||||
coordinator = hass.data[DOMAIN]["coordinator"]
|
||||
|
||||
entities = [
|
||||
SystemStateSwitch(coordinator, device),
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
class SystemStateSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Select component to set system state """
|
||||
_attr_has_entity_name: bool = True
|
||||
_attr_device_class: SwitchDeviceClass = SwitchDeviceClass.SWITCH
|
||||
_attr_entity_category: EntityCategory = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self,
|
||||
coordinator: KoolnovaCoordinator, # pylint: disable=unused-argument
|
||||
device: Koolnova, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._device = device
|
||||
self._attr_name = f"{device.name} Global HVAC State"
|
||||
self._attr_device_info = device.device_info
|
||||
self._attr_unique_id = f"{DOMAIN}-Global-HVAC-State-switch"
|
||||
self._attr_is_on = bool(int(self._device.sys_state))
|
||||
if bool(int(self._device.sys_state)):
|
||||
self._attr_state = STATE_ON
|
||||
else:
|
||||
self._attr_state = STATE_OFF
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
""" Turn the entity on. """
|
||||
_LOGGER.debug("Turn on system")
|
||||
await self._device.async_set_sys_state(SysState.SYS_STATE_ON)
|
||||
self._attr_is_on = True
|
||||
self._attr_state = STATE_ON
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
""" Turn the entity off. """
|
||||
_LOGGER.debug("Turn off system")
|
||||
await self._device.async_set_sys_state(SysState.SYS_STATE_OFF)
|
||||
self._attr_is_on = False
|
||||
self._attr_state = STATE_OFF
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
""" Handle updated data from the coordinator """
|
||||
self._attr_is_on = bool(int(self.coordinator.data['sys']))
|
||||
_LOGGER.debug("[UPDATE] Switch State: {}".format(bool(int(self.coordinator.data['sys']))))
|
||||
if bool(int(self.coordinator.data['sys'])):
|
||||
self._attr_state = STATE_ON
|
||||
else:
|
||||
self._attr_state = STATE_OFF
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return True if entity is on."""
|
||||
return bool(int(self._device.sys_state))
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon of the entity."""
|
||||
return "mdi:power"
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"title": "testVBE_4",
|
||||
"config": {
|
||||
"flow_title": "test VBE 4 configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Configuration Client Modbus RTU",
|
||||
"description": "Informations de connexion sur le périphérique Koolnova",
|
||||
"data": {
|
||||
"Name": "Nom de l'appareil",
|
||||
"Device": "Appareil de communication RS485 Modbus",
|
||||
"Address": "Adresse du périphérique",
|
||||
"Baudrate": "Vitesse modbus",
|
||||
"Sizebyte": "Taille des données",
|
||||
"Parity": "Parité",
|
||||
"Stopbits": "Nombre de bits de stop",
|
||||
"Timeout": "Timeout",
|
||||
"Debug": "Debug"
|
||||
}
|
||||
},
|
||||
"areas": {
|
||||
"title": "Configuration d'une zone",
|
||||
"description": "Information sur la zone à configurer",
|
||||
"data": {
|
||||
"Name": "Nom de la zone",
|
||||
"Area_id": "Identifiant de la zone",
|
||||
"Other_area": "Ajouter une nouvelle zone"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connected to Koolnova system",
|
||||
"area_not_registered": "This Area is not registered to the Koolnova system",
|
||||
"area_already_configured": "This Area is already configured",
|
||||
"zone_id_error": "Area Id must an integer between 1 and 16"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user