diff --git a/custom_components/__init__.py b/custom_components/__init__.py new file mode 100644 index 0000000..e269463 --- /dev/null +++ b/custom_components/__init__.py @@ -0,0 +1,53 @@ +""" 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 + +_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, []) + + 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'] + + _LOGGER.debug("Appel de async_setup_entry - entry: entry_id={}, data={}".format(entry.entry_id, entry.data)) + try: + device = Koolnova(name, port, addr, baudrate, parity, bytesize, stopbits, timeout) + # connect to modbus client + await device.connect() + # update attributes + await device.update() + # record each area in device + _LOGGER.debug("Koolnova areas: {}".format(entry.data['areas'])) + for area in entry.data['areas']: + await device.add_manual_registered_zone(name=area['Name'], + id_zone=area['Zone_id']) + _LOGGER.debug("Koolnova device: {}".format(device)) + hass.data[DOMAIN].append(device) + 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)) \ No newline at end of file diff --git a/custom_components/climate.py b/custom_components/climate.py new file mode 100644 index 0000000..c9409af --- /dev/null +++ b/custom_components/climate.py @@ -0,0 +1,175 @@ +""" for Climate integration. """ +from __future__ import annotations +from datetime import timedelta +import logging + +from homeassistant.core import HomeAssistant +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.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 homeassistant.const import ( + TEMP_CELSIUS, + ATTR_TEMPERATURE, +) + +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__) +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +async def async_setup_entry(hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback): + """Setup switch entries""" + + entities = [] + for device in hass.data[DOMAIN]: + for area in device.areas: + _LOGGER.debug("Device: {} - Area: {}".format(device, area)) + entities.append(AreaClimateEntity(device, area)) + async_add_entities(entities) + +class AreaClimateEntity(ClimateEntity): + """ Reperesentation of a climate entity """ + # pylint: disable = too-many-instance-attributes + + _attr_supported_features: int = SUPPORT_FLAGS + _attr_temperature_unit: str = TEMP_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 + + def __init__(self, + device: Koolnova, # pylint: disable=unused-argument + area: Area, # pylint: disable=unused-argument + ) -> None: + """ Class constructor """ + 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 + _LOGGER.debug("[Climate {}] {} - {}".format(self._area.id_zone, self._area.fan_mode, FAN_TRANSLATION[int(self._area.fan_mode)])) + self._attr_fan_mode = FAN_TRANSLATION[int(self._area.fan_mode)] + _LOGGER.debug("[Climate {}] {} - {}".format(self._area.id_zone, self._area.clim_mode, self._translate_to_hvac_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.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)) + + 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.set_area_fan_mode(zone_id = self._area.id_zone, + mode = ZoneFanMode(opt)) + await self._update_state() + + 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.set_area_clim_mode(zone_id = self._area.id_zone, + mode = ZoneClimMode(opt)) + await self._update_state() + + async def async_turn_on(self) -> None: + """ turn the entity on """ + _LOGGER.debug("[Climate {}] turn on the entity".format(self._area.id_zone)) + #await self._update_state() + + async def async_turn_off(self) -> None: + """ turn the entity off """ + _LOGGER.debug("[Climate {}] turn off the entity".format(self._area.id_zone)) + #await self._update_state() + + async def _update_state(self) -> None: + """ Private update attributes """ + _LOGGER.debug("[Climate {}] _update_state".format(self._area.id_zone)) + # retreive current temperature from specific area + ret, up_area = await self._device.update_area(self._area.id_zone) + if not ret: + _LOGGER.error("[Climate {}] Cannot update area values") + return + self._area = up_area + _LOGGER.debug("[Climate {}] temp:{} - target:{} - state: {} - hvac:{} - fan:{}".format(self._area.id_zone, + self._area.real_temp, + self._area.order_temp, + self._area.state, + self._area.clim_mode, + self._area.fan_mode)) + self._attr_current_temperature = self._area.real_temp + self._attr_target_temperature = self._area.order_temp + self._attr_hvac_mode = self._translate_to_hvac_mode() + self._attr_fan_mode = FAN_TRANSLATION[int(self._area.fan_mode)] + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """ Retreive latest values """ + await self._update_state() \ No newline at end of file diff --git a/custom_components/config_flow.py b/custom_components/config_flow.py new file mode 100644 index 0000000..d9e82d4 --- /dev/null +++ b/custom_components/config_flow.py @@ -0,0 +1,199 @@ +""" 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("Discover", default="MANUAL"): vol.In(["MANUAL", "AUTOMATIC"]) + } + ) + + if user_input: + # second call + _LOGGER.debug("config_flow [user] - Step 1b -> On a reçu les valeurs: {}".format(user_input)) + # 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"]) + try: + await self._conn.connect() + if not self._conn.connected(): + raise CannotConnectError(reason="Client Modbus not connected") + _LOGGER.debug("test communication with koolnova system") + ret, _ = await self._conn.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 = {} + zone_form = vol.Schema( + { + vol.Required("Name", default="zone"): vol.Coerce(str), + vol.Required("Zone_id", default=1): vol.Coerce(int), + vol.Optional("Other_area", default=False): cv.boolean + } + ) + + if user_input: + # second call + try: + # test if zone_id is already configured + for area in self._user_inputs["areas"]: + if user_input['Zone_id'] == area['Zone_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.connect() + if user_input['Zone_id'] > NB_ZONE_MAX: + raise ZoneIdError(reason="Zone_Id must be between 1 and 16") + _LOGGER.debug("test area registered with id: {}".format(user_input['Zone_id'])) + # test if area is configured into koolnova system + ret, _ = await self._conn.zone_registered(user_input["Zone_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['Zone_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['Zone_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" \ No newline at end of file diff --git a/custom_components/const.py b/custom_components/const.py new file mode 100644 index 0000000..c9965ff --- /dev/null +++ b/custom_components/const.py @@ -0,0 +1,118 @@ +""" 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, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, +) +from .koolnova.const import ( + GlobalMode, + Efficiency, + 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 efficiency" +EFF_POS_2 = "low efficiency" +EFF_POS_3 = "Medium efficiency" +EFF_POS_4 = "High efficiency" +EFF_POS_5 = "Higher efficiency" + +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, +] + +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, +] \ No newline at end of file diff --git a/custom_components/koolnova_bms/koolnova/__init__.py b/custom_components/koolnova/__init__.py similarity index 100% rename from custom_components/koolnova_bms/koolnova/__init__.py rename to custom_components/koolnova/__init__.py diff --git a/custom_components/koolnova_bms/koolnova/const.py b/custom_components/koolnova/const.py similarity index 81% rename from custom_components/koolnova_bms/koolnova/const.py rename to custom_components/koolnova/const.py index 371ec9e..d57accc 100644 --- a/custom_components/koolnova_bms/koolnova/const.py +++ b/custom_components/koolnova/const.py @@ -33,10 +33,16 @@ 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 @@ -44,13 +50,20 @@ class ZoneFanMode(Enum): FAN_HIGH = 3 FAN_AUTO = 4 + def __int__(self): + return self.value + class ZoneClimMode(Enum): - COLD = 1 - HOT = 2 + 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 @@ -60,6 +73,15 @@ REG_TEMP_REAL = 3 # 40004, 40008, 40012, etc ... 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 @@ -82,6 +104,9 @@ class FlowEngine(Enum): MANUAL_HIGH = 3 AUTO = 4 + def __int__(self): + return self.value + # Communication Modbus REG_COMM = 76 @@ -98,6 +123,9 @@ class Efficiency(Enum): HIGH_EFF = 4 HIGHER_EFF = 5 + def __int__(self): + return self.value + REG_CLIM_ID = 79 REG_SYS_STATE = 80 @@ -105,6 +133,9 @@ class SysState(Enum): SYS_STATE_OFF = 0 SYS_STATE_ON = 1 + def __int__(self): + return self.value + REG_GLOBAL_MODE = 81 class GlobalMode(Enum): @@ -113,3 +144,6 @@ class GlobalMode(Enum): HEATING_FLOOR = 4 REFRESHING_FLOOR = 5 HEATING_FLOOR_2 = 6 + + def __int__(self): + return self.value diff --git a/custom_components/koolnova/device.py b/custom_components/koolnova/device.py new file mode 100644 index 0000000..15ce064 --- /dev/null +++ b/custom_components/koolnova/device.py @@ -0,0 +1,624 @@ +""" local API to manage system, units and zones """ + +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 Zone 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('Zone(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 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._units = [] + 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: + _LOGGER.debug("idx found: {}".format(_idx)) + _idx = _areas_found[0] + return True, _idx + + async def update(self) -> bool: + ''' update values from modbus ''' + _LOGGER.debug("Retreive system status ...") + ret, self._sys_state = await self._client.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.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.efficiency() + if not ret: + _LOGGER.error("Error retreiving efficiency") + self._efficiency = const.Efficiency.LOWER_EFF + + #await asyncio.sleep(0.1) + + #_LOGGER.debug("Retreive units ...") + #for idx in range(1, const.NUM_OF_ENGINES + 1): + # _LOGGER.debug("Unit id: {}".format(idx)) + # unit = Unit(unit_id = idx) + # ret, unit.flow_engine = await self._client.flow_engine(unit_id = idx) + # ret, unit.flow_state = await self._client.flow_state_engine(unit_id = idx) + # ret, unit.order_temp = await self._client.order_temp_engine(unit_id = idx) + # self._units.append(unit) + # await asyncio.sleep(0.1) + + return True + + async def connect(self) -> bool: + ''' connect to the modbus serial server ''' + ret = True + await self._client.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 discover_zones(self) -> None: + ''' Set all registered zones for system ''' + if not self._client.connected: + raise ModbusConnexionError('Client Modbus not connected') + zones_lst = await self._client.discover_registered_zones() + 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 add_manual_registered_zone(self, + name:str = "", + id_zone:int = 0) -> bool: + ''' Add zone manually to koolnova System ''' + if not self._client.connected: + raise ModbusConnexionError('Client Modbus not connected') + + ret, zone_dict = await self._client.zone_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("Zones 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 update_area(self, zone_id:int = 0) -> bool: + """ update area """ + ret, infos = await self._client.zone_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 value 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] + + def get_units(self) -> list: + ''' get units ''' + return self._units + + @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 + + @property + def global_mode(self) -> const.GlobalMode: + ''' Get Global Mode ''' + return self._global_mode + + async def 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.set_global_mode(val) + if not ret: + 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 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.set_efficiency(val) + if not ret: + 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 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') + ret = await self._client.set_system_status(val) + if not ret: + raise UpdateValueError('Error writing to modbus updated value') + self._sys_state = val + + async def get_area_temp(self, + zone_id:int, + ) -> float: + """ get current temp of specific Area """ + ret, temp = await self._client.area_temp(id_zone = zone_id) + if not ret: + _LOGGER.error("Error reading temp for area with ID: {}".format(zone_id)) + return False + for idx, area in enumerate(self._areas): + if area.id_zone == zone_id: + # update areas list value from modbus response + self._areas[idx].real_temp = temp + + return temp + + async def set_area_target_temp(self, + zone_id:int, + temp:float, + ) -> bool: + """ set target temp of specific area """ + ret = await self._client.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 + for idx, area in enumerate(self._areas): + if area.id_zone == zone_id: + # update areas list value from modbus response + self._areas[idx].order_temp = temp + return True + + async def get_area_target_temp(self, + zone_id:int, + ) -> float: + """ get target temp of specific area """ + ret, temp = await self._client.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 + for idx, area in enumerate(self._areas): + if area.id_zone == zone_id: + # update areas list value from modbus response + self._areas[idx].order_temp = temp + return temp + + async def 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: + return False + + if mode == const.ZoneClimMode.OFF: + _LOGGER.debug("Set area state to OFF") + ret = await self._client.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.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.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 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: + 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.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 Unit: + ''' koolnova Unit class ''' + + def __init__(self, + unit_id:int = 0, + flow_engine:int = 0, + flow_state:const.FlowEngine = const.FlowEngine.AUTO, + order_temp:float = 0 + ) -> None: + ''' Constructor class ''' + self._unit_id = unit_id + self._flow_engine = flow_engine + self._flow_state = flow_state + self._order_temp = order_temp + + @property + def unit_id(self) -> int: + ''' Get Unit ID ''' + return self._unit_id + + @unit_id.setter + def unit_id(self, val:int) -> None: + ''' Set Unit ID ''' + if not isinstance(val, int): + raise AssertionError('Input variable must be Int') + if val > const.NUM_OF_ENGINES: + raise NumUnitError('Unit ID must be lower than {}'.format(const.NUM_OF_ENGINES)) + self._unit_id = val + + @property + def flow_engine(self) -> int: + ''' Get Flow Engine ''' + return self._flow_engine + + @flow_engine.setter + def flow_engine(self, val:int) -> None: + ''' Set Flow 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('Flow Engine value ({}) must be between {} and {}'.format(val, + const.FLOW_ENGINE_VAL_MIN, + const.FLOW_ENGINE_VAL_MAX)) + self._flow_engine = val + + @property + def flow_state(self) -> const.FlowEngine: + ''' Get Flow State ''' + return self._flow_state + + @flow_state.setter + def flow_state(self, val:const.FlowEngine) -> None: + ''' Set Flow State ''' + if not isinstance(val, const.FlowEngine): + raise AssertionError('Input variable must be Enum FlowEngine') + self._flow_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 Flow 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._flow_engine = val + + def __repr__(self) -> str: + ''' repr method ''' + return repr('Unit(Id:{}, Flow Engine:{}, Flow State:{}, Order Temp:{})'.format(self._unit_id, + self._flow_engine, + self._flow_state, + self._order_temp)) + +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 \ No newline at end of file diff --git a/custom_components/koolnova/operations.py b/custom_components/koolnova/operations.py new file mode 100644 index 0000000..c23a8b8 --- /dev/null +++ b/custom_components/koolnova/operations.py @@ -0,0 +1,504 @@ +""" 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) -> 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) + + 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) -> 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) + + pymodbus_apply_logging_config("DEBUG") + + async def __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 __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 __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 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 discover_registered_zones(self) -> list: + ''' Discover all zones registered to the system ''' + regs, ret = await self.__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 zone_registered(self, + zone_id:int = 0, + ) -> (bool, dict): + ''' Get Zone 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.__read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)), + count = const.NUM_REG_PER_ZONE) + if not ret: + raise ReadRegistersError("Read holding regsiter error") + 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 system_status(self) -> (bool, const.SysState): + ''' Read system status register ''' + reg, ret = await self.__read_register(const.REG_SYS_STATE) + if not ret: + _LOGGER.error('Error retreive system status') + reg = 0 + return ret, const.SysState(reg) + + async def set_system_status(self, + opt:const.SysState, + ) -> bool: + ''' Write system status ''' + ret = await self.__write_register(reg = const.REG_SYS_STATE, val = int(opt)) + if not ret: + _LOGGER.error('Error writing system status') + return ret + + async def global_mode(self) -> (bool, const.GlobalMode): + ''' Read global mode ''' + reg, ret = await self.__read_register(const.REG_GLOBAL_MODE) + if not ret: + _LOGGER.error('Error retreive global mode') + reg = 0 + return ret, const.GlobalMode(reg) + + async def set_global_mode(self, + opt:const.GlobalMode, + ) -> bool: + ''' Write global mode ''' + ret = await self.__write_register(reg = const.REG_GLOBAL_MODE, val = int(opt)) + if not ret: + _LOGGER.error('Error writing global mode') + return ret + + async def efficiency(self) -> (bool, const.Efficiency): + ''' read efficiency/speed ''' + reg, ret = await self.__read_register(const.REG_EFFICIENCY) + if not ret: + _LOGGER.error('Error retreive efficiency') + reg = 0 + return ret, const.Efficiency(reg) + + async def set_efficiency(self, + opt:const.GlobalMode, + ) -> bool: + ''' Write efficiency ''' + ret = await self.__write_register(reg = const.REG_EFFICIENCY, val = int(opt)) + if not ret: + _LOGGER.error('Error writing efficiency') + return ret + + async def flow_engines(self) -> (bool, list): + ''' read flow engines AC1, AC2, AC3, AC4 ''' + engines_lst = [] + regs, ret = await self.__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 flow engines') + return ret, engines_lst + + async def flow_engine(self, + unit_id:int = 0, + ) -> (bool, int): + ''' read flow unit specified by unit id ''' + if unit_id < 1 or unit_id > 4: + raise UnitIdError("Unit Id must be between 1 and 4") + reg, ret = await self.__read_register(const.REG_START_FLOW_ENGINE + (unit_id - 1)) + if not ret: + _LOGGER.error('Error retreive flow engine for id:{}'.format(unit_id)) + reg = 0 + return ret, reg + + async def flow_state_engine(self, + unit_id:int = 0, + ) -> (bool, const.FlowEngine): + if unit_id < 1 or unit_id > 4: + raise UnitIdError("Unit Id must be between 1 and 4") + reg, ret = await self.__read_register(const.REG_START_FLOW_STATE_ENGINE + (unit_id - 1)) + if not ret: + _LOGGER.error('Error retreive flow state for id:{}'.format(unit_id)) + reg = 0 + return ret, const.FlowEngine(reg) + + async def order_temp_engine(self, + unit_id:int = 0, + ) -> (bool, float): + if unit_id < 1 or unit_id > 4: + raise UnitIdError("Unit Id must be between 1 and 4") + reg, ret = await self.__read_register(const.REG_START_ORDER_TEMP + (unit_id - 1)) + if not ret: + _LOGGER.error('Error retreive order temp for id:{}'.format(unit_id)) + reg = 0 + return ret, reg / 2 + + async def orders_temp(self) -> (bool, list): + ''' read orders temperature AC1, AC2, AC3, AC4 ''' + engines_lst = [] + regs, ret = await self.__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 flow engines registers') + return ret, engines_lst + + async def 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.__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 zone order temperature') + + return ret + + async def area_temp(self, + id_zone:int = 0, + ) -> (bool, float): + """ get temperature of specific area id """ + reg, ret = await self.__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 area_target_temp(self, + id_zone:int = 0, + ) -> (bool, float): + """ get target temperature of specific area id """ + reg, ret = await self.__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 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.__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 area_state_and_register(self, + id_zone:int = 0, + ) -> (bool, const.ZoneRegister, const.ZoneState): + """ get area state and register """ + reg, ret = await self.__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 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('Zone Id must be between 1 to 16') + # retreive values to combine the new state with register read + ret, register, _ = await self.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.__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 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.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.__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 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.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.__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 diff --git a/custom_components/koolnova_bms/__init__.py b/custom_components/koolnova_bms/__init__.py deleted file mode 100644 index b8f2c4f..0000000 --- a/custom_components/koolnova_bms/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -"""The koolnova BMS modbus integration.""" # pylint: disable=invalid-name - -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.typing import HomeAssistantType - -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME, CONF_DEVICE_ID - -from .const import DOMAIN -#from .wfrac.device import Device - -_LOGGER = logging.getLogger(__name__) - -COMPONENT_TYPES = ["sensor", "climate", "select"] - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): - """ Set up koolnova from a config entry. """ - - _LOGGER.info('async_setup_entry') -# if DOMAIN not in hass.data: -# hass.data[DOMAIN] = [] -# -# device: str = entry.data[CONF_HOST] -# name: str = entry.data[CONF_NAME] -# device_id: str = entry.data[CONF_DEVICE_ID] -# operator_id: str = entry.data[CONF_OPERATOR_ID] -# port: int = entry.data[CONF_PORT] -# airco_id: str = entry.data[CONF_AIRCO_ID] - -# try: -# api = Device(hass, name, device, port, device_id, operator_id, airco_id) -# await api.update() # initial update to get fresh values -# hass.data[DOMAIN].append(api) -# except Exception as ex: # pylint: disable=broad-except -# _LOGGER.warning("Something whent wrong setting up device [%s] %s", device, ex) -# -# for component in COMPONENT_TYPES: -# hass.async_create_task(hass.config_entries.async_forward_entry_setup(entry, component)) -# -# return True - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Handle removal of an entry.""" - _LOGGER.info('async_unload_entry') -# if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): -# hass.data[DOMAIN].pop(entry.entry_id) -# return unloaded - -async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload config entry.""" - _LOGGER.info('async_reload_entry') -# await async_unload_entry(hass, entry) -# await async_setup_entry(hass, entry) - -async def async_remove_entry(hass, entry: ConfigEntry) -> None: - """Handle removal of an entry.""" - _LOGGER.info('async_remove_entry') -# for device in hass.data[DOMAIN]: -# temp_device: Device = device -# if temp_device.host == entry.data[CONF_HOST]: -# try: -# await temp_device.delete_account() -# _LOGGER.info( -# "Deleted operator ID [%s] from airco [%s]", -# temp_device.operator_id, -# temp_device.airco_id, -# ) -# hass.data[DOMAIN].remove(temp_device) -# except Exception as ex: # pylint: disable=broad-except -# _LOGGER.warning( -# "Something whent wrong deleting account from airco [%s] %s", -# temp_device.name, -# ex, -# ) diff --git a/custom_components/koolnova_bms/climate.py b/custom_components/koolnova_bms/climate.py deleted file mode 100644 index 9bf547c..0000000 --- a/custom_components/koolnova_bms/climate.py +++ /dev/null @@ -1,153 +0,0 @@ -""" for Climate integration.""" -from __future__ import annotations -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.climate import ( - ClimateEntity, - ConfigEntry, -) -from homeassistant.components.climate.const import HVACMode, FAN_AUTO -from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE -from homeassistant.util import Throttle -from homeassistant.const import CONF_HOST -from homeassistant.helpers import config_validation as cv, entity_platform - -from .koolnova.device import MIN_TIME_BETWEEN_UPDATES, Koolnova -from .const import ( - DOMAIN, - FAN_MODE_TRANSLATION, - HVAC_TRANSLATION, - SERVICE_SET_HORIZONTAL_SWING_MODE, - SERVICE_SET_VERTICAL_SWING_MODE, - SUPPORT_FLAGS, - SWING_HORIZONTAL_AUTO, - SWING_VERTICAL_AUTO, - SUPPORT_SWING_MODES, - SUPPORTED_FAN_MODES, - SUPPORTED_HVAC_MODES, - SWING_3D_AUTO, - SWING_MODE_TRANSLATION, - HORIZONTAL_SWING_MODE_TRANSLATION, -) - -_LOGGER = logging.getLogger(__name__) -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) - -async def async_setup_entry(hass, entry: ConfigEntry, async_add_entities): - """Setup climate entities""" - for device in hass.data[DOMAIN]: - if device.host == entry.data[CONF_HOST]: - _LOGGER.info("Setup climate for: %s, %s", device.name, device.airco_id) - async_add_entities([KoolnovaZoneClimate(device)]) - - platform = entity_platform.async_get_current_platform() - -class KoolnovaZoneClimate(ClimateEntity): - """Representation of a climate entity""" - - _attr_supported_features: int = SUPPORT_FLAGS - _attr_temperature_unit: str = TEMP_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_AUTO - _attr_min_temp: float = 16 - _attr_max_temp: float = 30 - - def __init__(self, device: Device) -> None: - self._device = device - self._attr_name = device.name - self._attr_device_info = device.device_info - self._attr_unique_id = f"{DOMAIN}-{self._device.airco_id}-climate" - self._update_state() - - async def async_set_temperature(self, **kwargs) -> None: - """Set new target temperature.""" - opts = {AirconCommands.PresetTemp: kwargs.get(ATTR_TEMPERATURE)} - - if "hvac_mode" in kwargs: - hvac_mode = kwargs.get("hvac_mode") - opts.update( - { - AirconCommands.OperationMode: self._device.airco.OperationMode - if hvac_mode == HVACMode.OFF - else HVAC_TRANSLATION[hvac_mode], - AirconCommands.Operation: hvac_mode != HVACMode.OFF, - } - ) - - await self._device.set_airco(opts) - self._update_state() - - async def async_set_fan_mode(self, fan_mode: str) -> None: - """Set new target fan mode.""" - await self._device.set_airco( - {AirconCommands.AirFlow: FAN_MODE_TRANSLATION[fan_mode]} - ) - self._update_state() - - async def async_turn_on(self) -> None: - """Turn the entity on.""" - await self._device.set_airco({AirconCommands.Operation: True}) - self._update_state() - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - await self._device.set_airco( - { - AirconCommands.OperationMode: self._device.airco.OperationMode - if hvac_mode == HVACMode.OFF - else HVAC_TRANSLATION[hvac_mode], - AirconCommands.Operation: hvac_mode != HVACMode.OFF, - } - ) - self._update_state() - - async def async_turn_off(self) -> None: - """Turn the entity off.""" - await self._device.set_airco({AirconCommands.Operation: False}) - self._update_state() - - def _update_state(self) -> None: - """Private update attributes""" - airco = self._device.airco - - self._attr_target_temperature = airco.PresetTemp - self._attr_current_temperature = airco.IndoorTemp - self._attr_fan_mode = list(FAN_MODE_TRANSLATION.keys())[airco.AirFlow] - self._attr_swing_mode = ( - SWING_3D_AUTO - if airco.Entrust - else list(SWING_MODE_TRANSLATION.keys())[airco.WindDirectionUD] - ) - # self._attr_horizontal_swing_mode = list( - # HORIZONTAL_SWING_MODE_TRANSLATION.keys() - # )[airco.WindDirectionLR] - self._attr_hvac_mode = list(HVAC_TRANSLATION.keys())[airco.OperationMode] - - if airco.Operation is False: - self._attr_hvac_mode = HVACMode.OFF - else: - _new_mode: HVACMode = None - _mode = airco.OperationMode - if _mode == 0: - _new_mode = HVACMode.AUTO - elif _mode == 1: - _new_mode = HVACMode.COOL - elif _mode == 2: - _new_mode = HVACMode.HEAT - elif _mode == 3: - _new_mode = HVACMode.FAN_ONLY - elif _mode == 4: - _new_mode = HVACMode.DRY - self._attr_hvac_mode = _new_mode - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Retrieve latest state.""" - await self._device.update() - self._update_state() - diff --git a/custom_components/koolnova_bms/config_flow.py b/custom_components/koolnova_bms/config_flow.py deleted file mode 100644 index 957e201..0000000 --- a/custom_components/koolnova_bms/config_flow.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Config flow Koolnova""" -from __future__ import annotations - -import logging -from typing import Any -from uuid import uuid4 -from functools import partial - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components import zeroconf -from homeassistant import config_entries, exceptions -from homeassistant.const import ( - CONF_HOST, - CONF_PORT, - CONF_NAME, - CONF_BASE, - CONF_DEVICE_ID, - CONF_FORCE_UPDATE, -) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResult - -#from .const import CONF_OPERATOR_ID, CONF_AIRCO_ID, DOMAIN -#from .wfrac.repository import Repository - -_LOGGER = logging.getLogger(__name__) - -class KoolnovaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow.""" - - VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - DOMAIN = DOMAIN - - async def async_step_user(self, user_input=None): - """Handle adding device manually.""" - - field = partial(self._field, user_input) - data_schema = vol.Schema({ - field(CONF_NAME, vol.Required, "Airco unknown") : cv.string, - field(CONF_HOST, vol.Required) : cv.string, - field(CONF_PORT, vol.Optional, 51443): cv.port, - field(CONF_FORCE_UPDATE, vol.Optional, False): cv.boolean, - }) - - return await self._async_create_common( - step_id="user", - data_schema=data_schema, - user_input=user_input - ) - - @property - def _name(self) -> str | None: - return self.context.get(CONF_NAME) - -# pylint: disable=too-few-public-methods - -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 strings.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 - # Errors will only be displayed to the user if the key is actually in the form (or - # CONF_BASE for a general error), so we'll check the schema (seems weird there - # isn't a more efficient way to do this...) - if key not in {k.schema for k in schema}: - key = CONF_BASE - return ({key : self.error_name}, self._extra_info or {}) - -class CannotConnect(KnownError): - """Error to indicate we cannot connect.""" - error_name = "cannot_connect" - -class InvalidHost(KnownError): - """Error to indicate there is an invalid hostname.""" - error_name = "cannot_connect" - applies_to_field = CONF_HOST - -class HostAlreadyConfigured(KnownError): - """Error to indicate there is an duplicate hostname.""" - error_name = "host_already_configured" - applies_to_field = CONF_HOST - -class InvalidName(KnownError): - """Error to indicate there is an invalid hostname.""" - error_name = "name_invalid" - applies_to_field = CONF_NAME - -class TooManyDevicesRegistered(KnownError): - """Error to indicate that there are too many devices registered""" - error_name = "too_many_devices_registered" - applies_to_field = CONF_BASE diff --git a/custom_components/koolnova_bms/const.py b/custom_components/koolnova_bms/const.py deleted file mode 100644 index 45d9cf6..0000000 --- a/custom_components/koolnova_bms/const.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Constants used by the koolnova-bms component.""" - -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE -from homeassistant.components.climate.const import ( - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_AUTO, - ClimateEntityFeature, - HVACMode, - FAN_AUTO, -) - -DOMAIN = "koolnova_bms" -DEVICES = "wf-rac-devices" - -CONF_OPERATOR_ID = "operator_id" -CONF_AIRCO_ID = "airco_id" -ATTR_DEVICE_ID = "device_id" -ATTR_CONNECTED_ACCOUNTS = "connected_accounts" - -ATTR_INSIDE_TEMPERATURE = "inside_temperature" - -SENSOR_TYPE_TEMPERATURE = "temperature" - -SENSOR_TYPES = { - ATTR_INSIDE_TEMPERATURE: { - CONF_NAME: "Inside Temperature", - CONF_ICON: "mdi:thermometer", - CONF_TYPE: SENSOR_TYPE_TEMPERATURE, - }, -} - -SUPPORT_FLAGS = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE -) - -SUPPORTED_HVAC_MODES = [ - HVACMode.OFF, - HVACMode.AUTO, - HVACMode.COOL, - HVACMode.DRY, - HVACMode.HEAT, - HVACMode.FAN_ONLY, -] - -HVAC_TRANSLATION = { - HVAC_MODE_AUTO: 0, - HVAC_MODE_COOL: 1, - HVAC_MODE_HEAT: 2, - HVAC_MODE_FAN_ONLY: 3, - HVAC_MODE_DRY: 4, -} - -FAN_MODE_1 = "1 Lowest" -FAN_MODE_2 = "2 Low" -FAN_MODE_3 = "3 High" -FAN_MODE_4 = "4 Highest" - -FAN_MODE_TRANSLATION = { - FAN_AUTO: 0, - FAN_MODE_1: 1, - FAN_MODE_2: 2, - FAN_MODE_3: 3, - FAN_MODE_4: 4, -} - -SUPPORTED_FAN_MODES = [ - FAN_AUTO, - FAN_MODE_1, - FAN_MODE_2, - FAN_MODE_3, - FAN_MODE_4, -] - -OPERATION_LIST = { - # HVAC_MODE_OFF: "Off", - HVAC_MODE_HEAT: "Heat", - HVAC_MODE_COOL: "Cool", - HVAC_MODE_AUTO: "Auto", - HVAC_MODE_DRY: "Dry", - HVAC_MODE_FAN_ONLY: "Fan", -} diff --git a/custom_components/koolnova_bms/koolnova/__pycache__/__init__.cpython-39.pyc b/custom_components/koolnova_bms/koolnova/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index 3ff7911..0000000 Binary files a/custom_components/koolnova_bms/koolnova/__pycache__/__init__.cpython-39.pyc and /dev/null differ diff --git a/custom_components/koolnova_bms/koolnova/__pycache__/const.cpython-39.pyc b/custom_components/koolnova_bms/koolnova/__pycache__/const.cpython-39.pyc deleted file mode 100644 index 719bac8..0000000 Binary files a/custom_components/koolnova_bms/koolnova/__pycache__/const.cpython-39.pyc and /dev/null differ diff --git a/custom_components/koolnova_bms/koolnova/__pycache__/device.cpython-39.pyc b/custom_components/koolnova_bms/koolnova/__pycache__/device.cpython-39.pyc deleted file mode 100644 index b583878..0000000 Binary files a/custom_components/koolnova_bms/koolnova/__pycache__/device.cpython-39.pyc and /dev/null differ diff --git a/custom_components/koolnova_bms/koolnova/__pycache__/operations.cpython-39.pyc b/custom_components/koolnova_bms/koolnova/__pycache__/operations.cpython-39.pyc deleted file mode 100644 index 7c261a8..0000000 Binary files a/custom_components/koolnova_bms/koolnova/__pycache__/operations.cpython-39.pyc and /dev/null differ diff --git a/custom_components/koolnova_bms/koolnova/app.py b/custom_components/koolnova_bms/koolnova/app.py deleted file mode 100644 index e69de29..0000000 diff --git a/custom_components/koolnova_bms/koolnova/device.py b/custom_components/koolnova_bms/koolnova/device.py deleted file mode 100644 index 4f6c90d..0000000 --- a/custom_components/koolnova_bms/koolnova/device.py +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- - -# @author: sinseman44 -# @date: 10/2023 -# @brief: System, Unit and Zone classes - -########################################################################### -# import external modules - -import re, sys, os -import logging as log -import asyncio - -########################################################################### -# import internal modules - -from . import const -from .operations import Operations, ModbusConnexionError - -########################################################################### -# class & methods - -logger = log.getLogger('koolnova_bms') - -class Koolnova: - ''' koolnova Device class ''' - - def __init__(self, - port:str = "", - timeout:int = 0, - ) -> None: - ''' Class constructor ''' - self._client = Operations(port=port, timeout=timeout) - self._global_mode = const.GlobalMode.COLD - self._efficiency = const.Efficiency.LOWER_EFF - self._sys_state = const.SysState.SYS_STATE_OFF - self._units = [] - self._zones = [] - - async def connect(self) -> bool: - ''' connect to the modbus serial server ''' - await self._client.connect() - if not self.connected(): - raise ClientNotConnectedError("Client Modbus connexion error") - - logger.info("Retreive system status ...") - ret, self._sys_state = await self._client.system_status() - if not ret: - logger.error("Error retreiving system status") - self._sys_state = const.SysState.SYS_STATE_OFF - - logger.info("Retreive global mode ...") - ret, self._global_mode = await self._client.global_mode() - if not ret: - logger.error("Error retreiving global mode") - self._global_mode = const.GlobalMode.COLD - - logger.info("Retreive efficiency ...") - ret, self._efficiency = await self._client.efficiency() - if not ret: - logger.error("Error retreiving efficiency") - self._efficiency = const.Efficiency.LOWER_EFF - - await asyncio.sleep(0.5) - - logger.info("Retreive units ...") - for idx in range(1, const.NUM_OF_ENGINES + 1): - logger.debug("Unit id: {}".format(idx)) - unit = Unit(unit_id = idx) - ret, unit.flow_engine = await self._client.flow_engine(unit_id = idx) - ret, unit.flow_state = await self._client.flow_state_engine(unit_id = idx) - ret, unit.order_temp = await self._client.order_temp_engine(unit_id = idx) - self._units.append(unit) - await asyncio.sleep(0.5) - - return True - - 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 discover_zones(self) -> None: - ''' Set all registered zones for system ''' - if not self._client.connected: - raise ModbusConnexionError('Client Modbus not connected') - zones_lst = await self._client.discover_registered_zones() - for zone in zones_lst: - self._zones.append(Zone(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 add_manual_registered_zone(self, - id_zone:int = 0) -> bool: - ''' Add zone to koolnova System ''' - if not self._client.connected: - raise ModbusConnexionError('Client Modbus not connected') - - ret, zone_dict = await self._client.zone_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._zones: - if id_zone == zone.id_zone: - logger.error('Zone registered with ID: {} is already saved') - return False - - self._zones.append(Zone(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("Zones registered: {}".format(self._zones)) - return True - - def get_zones(self) -> list: - ''' get zones ''' - return self._zones - - def get_zone(self, zone_id:int = 0) -> str: - ''' get specific zone ''' - return self._zones[zone_id - 1] - - def get_units(self) -> list: - ''' get units ''' - return self._units - - @property - def global_mode(self) -> const.GlobalMode: - ''' Get Global Mode ''' - return self._global_mode - - @global_mode.setter - def global_mode(self, val:const.GlobalMode) -> None: - ''' Set Global Mode ''' - if not isinstance(val, const.GlobalMode): - raise AssertionError('Input variable must be Enum GlobalMode') - self._global_mode = val - - @property - def efficiency(self) -> const.Efficiency: - ''' Get Efficiency ''' - return self._efficiency - - @efficiency.setter - def efficiency(self, val:const.Efficiency) -> None: - ''' Set Efficiency ''' - if not isinstance(val, const.Efficiency): - raise AssertionError('Input variable must be Enum Efficiency') - self._efficiency = val - - @property - def sys_state(self) -> const.SysState: - ''' Get System State ''' - return self.sys_state - - @sys_state.setter - def sys_state(self, val:const.SysState) -> None: - ''' Set System State ''' - if not isinstance(val, const.SysState): - raise AssertionError('Input variable must be Enum SysState') - self._sys_state = val - - def __repr__(self) -> str: - ''' repr method ''' - return repr('System(Global Mode:{}, Efficiency:{}, State:{})'.format( self._global_mode,self._efficiency,self._sys_state)) - -class Unit: - ''' koolnova Unit class ''' - - def __init__(self, - unit_id:int = 0, - flow_engine:int = 0, - flow_state:const.FlowEngine = const.FlowEngine.AUTO, - order_temp:float = 0 - ) -> None: - ''' Constructor class ''' - self._unit_id = unit_id - self._flow_engine = flow_engine - self._flow_state = flow_state - self._order_temp = order_temp - - @property - def unit_id(self) -> int: - ''' Get Unit ID ''' - return self._unit_id - - @unit_id.setter - def unit_id(self, val:int) -> None: - ''' Set Unit ID ''' - if not isinstance(val, int): - raise AssertionError('Input variable must be Int') - if val > const.NUM_OF_ENGINES: - raise NumUnitError('Unit ID must be lower than {}'.format(const.NUM_OF_ENGINES)) - self._unit_id = val - - @property - def flow_engine(self) -> int: - ''' Get Flow Engine ''' - return self._flow_engine - - @flow_engine.setter - def flow_engine(self, val:int) -> None: - ''' Set Flow 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('Flow Engine value ({}) must be between {} and {}'.format(val, const.FLOW_ENGINE_VAL_MIN, const.FLOW_ENGINE_VAL_MAX)) - self._flow_engine = val - - @property - def flow_state(self) -> const.FlowEngine: - ''' Get Flow State ''' - return self._flow_state - - @flow_state.setter - def flow_state(self, val:const.FlowEngine) -> None: - ''' Set Flow State ''' - if not isinstance(val, const.FlowEngine): - raise AssertionError('Input variable must be Enum FlowEngine') - self._flow_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 Flow 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._flow_engine = val - - def __repr__(self) -> str: - ''' repr method ''' - return repr('Unit(Id:{}, Flow Engine:{}, Flow State:{}, Order Temp:{})'.format(self._unit_id, - self._flow_engine, - self._flow_state, - self._order_temp)) - -class Zone: - ''' koolnova Zone class ''' - - def __init__(self, - 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.COLD, - real_temp:float = 0, - order_temp:float = 0 - ) -> None: - ''' Class constructor ''' - 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 id_zone(self) -> int: - ''' Get Zone 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('Zone(Id:{}, State:{}, Register:{}, Fan:{}, Clim:{}, Real Temp:{}, Order Temp:{})'.format(self._id, - self._state, - self._register, - self._fan_mode, - self._clim_mode, - self._real_temp, - self._order_temp)) - -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 - diff --git a/custom_components/koolnova_bms/koolnova/operations.py b/custom_components/koolnova_bms/koolnova/operations.py deleted file mode 100644 index 1e6267b..0000000 --- a/custom_components/koolnova_bms/koolnova/operations.py +++ /dev/null @@ -1,308 +0,0 @@ -#!/usr/bin/env python3 -# -*- encoding: utf-8 -*- - -# @author: sinseman44 -# @date: 10/2023 -# @brief: Communicate with Koolnova BMS Modbus RTU - -########################################################################### -# import external modules - -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 - -########################################################################### -# import internal modules - -from . import const - -########################################################################### -# class & methods - -logger = log.getLogger('koolnova_bms') - -class Operations: - ''' koolnova BMS Modbus operations class ''' - - def __init__(self, port:str, timeout:int) -> None: - ''' Class constructor ''' - self._port = port - self._timeout = timeout - self._client = ModbusClient(port=self._port, - baudrate=const.DEFAULT_BAUDRATE, - parity=const.DEFAULT_PARITY, - stopbits=const.DEFAULT_STOPBITS, - bytesize=const.DEFAULT_BYTESIZE, - timeout=self._timeout) - pymodbus_apply_logging_config("DEBUG") - - async def __read_register(self, reg:int) -> (int, bool): - ''' Read one holding register (code 0x03) ''' - ret = True - rr = None - if not self._client.connected: - raise ModbusConnexionError('Client Modbus not connected') - try: - rr = await self._client.read_holding_registers(address=reg, count=1, slave=const.DEFAULT_ADDR) - if rr.isError(): - ret = False - except ModbusException as e: - logger.error("{}".format(e)) - ret = False - - if isinstance(rr, ExceptionResponse): - logger.error("Received modbus exception ({})".format(rr)) - ret = False - return rr.registers[0], ret - - async def __read_registers(self, start_reg:int, count:int) -> (int, bool): - ''' Read holding registers (code 0x03) ''' - ret = True - 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=const.DEFAULT_ADDR) - if rr.isError(): - ret = False - except ModbusException as e: - logger.error("{}".format(e)) - ret = False - - if isinstance(rr, ExceptionResponse): - logger.error("Received modbus exception ({})".format(rr)) - ret = False - return rr.registers, ret - - async def __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: - rq = await self._client.write_register(address=reg, value=val, slave=const.DEFAULT_ADDR) - if rq.isError(): - logger.error("Write error: {}".format(rq)) - ret = False - except ModbusException as e: - logger.error("{}".format(e)) - ret = False - - if isinstance(rq, ExceptionResponse): - logger.error("Received modbus exception ({})".format(rr)) - ret = False - return ret - - async def 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 ''' - self._client.close() - - async def discover_registered_zones(self) -> list: - ''' Discover all zones registered to the system ''' - regs, ret = await self.__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 zone_registered(self, zone_id:int = 0) -> (bool, dict): - ''' Get Zone Status from Id ''' - if zone_id > const.NB_ZONE_MAX or zone_id == 0: - raise ZoneIdError('Zone Id must be between 1 to 16') - zone_dict = {} - regs, ret = await self.__read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)), count = const.NUM_REG_PER_ZONE) - if not ret: - raise ReadRegistersError("Read holding regsiter error") - if const.ZoneRegister(regs[0] >> 1) == const.ZoneRegister.REGISTER_OFF: - 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 system_status(self) -> (bool, const.SysState): - ''' Read system status register ''' - reg, ret = await self.__read_register(const.REG_SYS_STATE) - if not ret: - logger.error('Error retreive system status') - reg = 0 - return ret, const.SysState(reg) - - async def global_mode(self) -> (bool, const.GlobalMode): - ''' Read global mode ''' - reg, ret = await self.__read_register(const.REG_GLOBAL_MODE) - if not ret: - logger.error('Error retreive global mode') - reg = 0 - return ret, const.GlobalMode(reg) - - async def efficiency(self) -> (bool, const.Efficiency): - ''' read efficiency/speed ''' - reg, ret = await self.__read_register(const.REG_EFFICIENCY) - if not ret: - logger.error('Error retreive efficiency') - reg = 0 - return ret, const.Efficiency(reg) - - async def flow_engines(self) -> (bool, list): - ''' read flow engines AC1, AC2, AC3, AC4 ''' - engines_lst = [] - regs, ret = await self.__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 flow engines') - return ret, engines_lst - - async def flow_engine(self, unit_id:int = 0) -> (bool, int): - ''' read flow unit specified by unit id ''' - if unit_id < 1 or unit_id > 4: - raise UnitIdError("Unit Id must be between 1 and 4") - reg, ret = await self.__read_register(const.REG_START_FLOW_ENGINE + (unit_id - 1)) - if not ret: - logger.error('Error retreive flow engine for id:{}'.format(unit_id)) - reg = 0 - return ret, reg - - async def flow_state_engine(self, unit_id:int = 0) -> (bool, const.FlowEngine): - if unit_id < 1 or unit_id > 4: - raise UnitIdError("Unit Id must be between 1 and 4") - reg, ret = await self.__read_register(const.REG_START_FLOW_STATE_ENGINE + (unit_id - 1)) - if not ret: - logger.error('Error retreive flow state for id:{}'.format(unit_id)) - reg = 0 - return ret, const.FlowEngine(reg) - - async def order_temp_engine(self, unit_id:int = 0) -> (bool, float): - if unit_id < 1 or unit_id > 4: - raise UnitIdError("Unit Id must be between 1 and 4") - reg, ret = await self.__read_register(const.REG_START_ORDER_TEMP + (unit_id - 1)) - if not ret: - logger.error('Error retreive order temp for id:{}'.format(unit_id)) - reg = 0 - return ret, reg / 2 - - async def orders_temp(self) -> (bool, list): - ''' read orders temperature AC1, AC2, AC3, AC4 ''' - engines_lst = [] - regs, ret = await self.__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 flow engines registers') - return ret, engines_lst - - async def set_zone_order_temp(self, zone_id:int = 0, val:float = 0.0) -> bool: - ''' Set zone order temp ''' - 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.__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 zone order temperature') - - return ret - - @property - def port(self) -> str: - ''' Get Port ''' - return self._port - - @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 diff --git a/custom_components/koolnova_bms/manifest.json b/custom_components/koolnova_bms/manifest.json deleted file mode 100644 index 08020ec..0000000 --- a/custom_components/koolnova_bms/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "domain": "koolnova_bms", - "name": "koolnova BMS Modbus RTU", - "codeowners": ["@vbenoit"], - "config_flow": true, - "documentation": "https://git.nas.benserv.fr/vincent/koolnova-BMS-Integration/src/branch/main/README.md", - "iot_class": "local_polling", - "issue_tracker": "https://git.nas.benserv.fr/vincent/koolnova-BMS-Integration/issues", - "integration_type": "hub", - "requirements": [ - "pymodbus>=3.5.4", - "pyserial>=3.5" - ], - "version": "0.1.0" -} diff --git a/custom_components/koolnova_bms/sensor.py b/custom_components/koolnova_bms/sensor.py deleted file mode 100644 index 342ee65..0000000 --- a/custom_components/koolnova_bms/sensor.py +++ /dev/null @@ -1,122 +0,0 @@ -""" for sensor integration. """ -# pylint: disable = too-few-public-methods - -from __future__ import annotations -from datetime import timedelta -import logging - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.const import ( - TEMP_CELSIUS, - CONF_HOST, - CONF_ERROR, -) -from homeassistant.util import Throttle -from homeassistant.helpers.entity import EntityCategory - -from .koolnova.device import Koolnova -from .const import ( - DOMAIN, - ATTR_INSIDE_TEMPERATURE, - CONF_OPERATOR_ID, - CONF_AIRCO_ID, - ATTR_DEVICE_ID, - ATTR_CONNECTED_ACCOUNTS, -) - -_LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) - -async def async_setup_entry(hass, entry, async_add_entities): - """Setup sensor entries""" - - for device in hass.data[DOMAIN]: - if device.host == entry.data[CONF_HOST]: - _LOGGER.info("Setup: %s, %s", device.name, device.airco_id) - entities = [ - TemperatureSensor(device, "Indoor", ATTR_INSIDE_TEMPERATURE), - DiagnosticsSensor(device, "Airco ID", CONF_AIRCO_ID), - DiagnosticsSensor(device, "Operator ID", CONF_OPERATOR_ID, True), - DiagnosticsSensor(device, "Device ID", ATTR_DEVICE_ID, True), - DiagnosticsSensor(device, "IP", CONF_HOST, True), - DiagnosticsSensor(device, "Accounts", ATTR_CONNECTED_ACCOUNTS, True), - DiagnosticsSensor(device, "Error", CONF_ERROR), - ] - if device.airco.Electric is not None: - entities.append(EnergySensor(device)) - - 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: Device, name: str, custom_type: str, enable=False) -> None: - """Initialize the sensor.""" - self._device = device - self._attr_name = f"{device.name} {name}" - self._attr_entity_registry_enabled_default = enable - self._custom_type = custom_type - self._attr_device_info = device.device_info - self._attr_native_unit_of_measurement = ( - "Accounts" if custom_type == ATTR_CONNECTED_ACCOUNTS else None - ) - self._attr_icon = ( - "mdi:account-group" if custom_type == ATTR_CONNECTED_ACCOUNTS else None - ) - self._attr_unique_id = ( - f"{DOMAIN}-{self._device.airco_id}-{self._custom_type}-sensor" - ) - self._update_state() - - def _update_state(self) -> None: - if self._custom_type == CONF_OPERATOR_ID: - self._attr_native_value = self._device.operator_id - elif self._custom_type == CONF_AIRCO_ID: - self._attr_native_value = self._device.airco_id - elif self._custom_type == CONF_HOST: - self._attr_native_value = self._device.host - elif self._custom_type == ATTR_DEVICE_ID: - self._attr_native_value = self._device.device_id - elif self._custom_type == ATTR_CONNECTED_ACCOUNTS: - self._attr_native_value = self._device.num_accounts - elif self._custom_type == CONF_ERROR: - self._attr_native_value = self._device.airco.ErrorCode - - async def async_update(self): - """Retrieve latest state.""" - self._update_state() - -class TemperatureSensor(SensorEntity): - """Representation of a Sensor.""" - - _attr_native_unit_of_measurement = TEMP_CELSIUS - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__(self, device: Device, name: str, custom_type: str) -> None: - """Initialize the sensor.""" - self._device = device - self._custom_type = custom_type - self._attr_name = f"{device.name} {name}" - self._attr_device_info = device.device_info - self._attr_unique_id = ( - f"{DOMAIN}-{self._device.airco_id}-{self._custom_type}-sensor" - ) - self._update_state() - - def _update_state(self) -> None: - if self._custom_type == ATTR_INSIDE_TEMPERATURE: - self._attr_native_value = self._device.airco.IndoorTemp - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self): - """Retrieve latest state.""" - self._update_state() diff --git a/custom_components/manifest.json b/custom_components/manifest.json new file mode 100644 index 0000000..8a13edb --- /dev/null +++ b/custom_components/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "testVBE_4", + "name": "Test Tuto VBE 4", + "codeowners": ["@sinseman44"], + "config_flow": true, + "documentation": "https://github.com/sinseman44/testVBE_4", + "issue_tracker": "https://github.com/sinseman44/testVBE_4/issues", + "integration_type": "device", + "iot_class": "calculated", + "quality_scale": "silver", + "version": "1.0.0" +} \ No newline at end of file diff --git a/custom_components/select.py b/custom_components/select.py new file mode 100644 index 0000000..72a3265 --- /dev/null +++ b/custom_components/select.py @@ -0,0 +1,134 @@ +""" for select component """ +# pylint: disable = too-few-public-methods + +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.select import SelectEntity +from homeassistant.util import Throttle + +from .const import ( + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, + GLOBAL_MODES, + GLOBAL_MODE_TRANSLATION, + EFF_MODES, + EFF_TRANSLATION, +) +from homeassistant.const import UnitOfTime +from .koolnova.device import Koolnova +from .koolnova.const import ( + GlobalMode, + Efficiency, +) + +_LOGGER = logging.getLogger(__name__) + +async def async_setup_entry(hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback): + """Setup select entries""" + + for device in hass.data[DOMAIN]: + _LOGGER.debug("Device: {}".format(device)) + entities = [ + GlobalModeSelect(device), + EfficiencySelect(device), + ] + async_add_entities(entities) + +class GlobalModeSelect(SelectEntity): + """Select component to set Global Mode """ + + def __init__(self, + device: Koolnova, + ) -> None: + super().__init__() + self._attr_options = GLOBAL_MODES + self._device = device + self._attr_name = f"{device.name} Global Mode" + self._attr_device_info = device.device_info + self._attr_icon = "mdi:cog-clockwise" + self._attr_unique_id = f"{DOMAIN}-GlobalMode-select" + self.select_option( + GLOBAL_MODE_TRANSLATION[int(self._device.global_mode)] + ) + + def _update_state(self) -> None: + """ update global mode """ + _LOGGER.debug("[GLOBAL MODE] _update_state") + self.select_option( + GLOBAL_MODE_TRANSLATION[int(self._device.global_mode)] + ) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug("[GLOBAL MODE] select_option: {}".format(option)) + self._attr_current_option = option + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug("[GLOBAL_MODE] async_select_option: {}".format(option)) + opt = 0 + for k,v in GLOBAL_MODE_TRANSLATION.items(): + if v == option: + opt = k + break + await self._device.set_global_mode(GlobalMode(opt)) + self.select_option(option) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("[GLOBAL MODE] async_update") + #await self._device.update() + self._update_state() + +class EfficiencySelect(SelectEntity): + """Select component to set Efficiency """ + + def __init__(self, + device: Koolnova, + ) -> None: + super().__init__() + self._attr_options = EFF_MODES + self._device = device + self._attr_name = f"{device.name} Efficiency" + self._attr_device_info = device.device_info + self._attr_icon = "mdi:wind-power-outline" + self._attr_unique_id = f"{DOMAIN}-Efficiency-select" + self.select_option( + EFF_TRANSLATION[int(self._device.efficiency)] + ) + + def _update_state(self) -> None: + """ update efficiency """ + _LOGGER.debug("[EFF] _update_state") + self.select_option( + EFF_TRANSLATION[int(self._device.efficiency)] + ) + + def select_option(self, option: str) -> None: + """Change the selected option.""" + _LOGGER.debug("[EFF] select_option: {}".format(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.set_efficiency(Efficiency(opt)) + self.select_option(option) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("[EFF] async_update") + #await self._device.update() + self._update_state() \ No newline at end of file diff --git a/custom_components/sensor.py b/custom_components/sensor.py new file mode 100644 index 0000000..364175b --- /dev/null +++ b/custom_components/sensor.py @@ -0,0 +1,111 @@ +""" Implementation du composant sensors """ +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.event import ( + async_track_time_interval, + async_track_state_change_event, +) +from .const import ( + DOMAIN +) +from homeassistant.const import UnitOfTime +from .koolnova.device import Koolnova + +_LOGGER = logging.getLogger(__name__) + +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 + """ + _LOGGER.debug("Calling async_setup_entry - datas: {}".format(entry.data)) + _LOGGER.debug("HASS data: {}".format(hass.data[DOMAIN])) + for device in hass.data[DOMAIN]: + _LOGGER.debug("Device: {}".format(device)) + entities = [ + DiagnosticsSensor(device, "Device", entry.data), + DiagnosticsSensor(device, "Address", entry.data), + DiagnosticsSensor(device, "Baudrate", entry.data), + DiagnosticsSensor(device, "Sizebyte", entry.data), + DiagnosticsSensor(device, "Parity", entry.data), + DiagnosticsSensor(device, "Stopbits", entry.data), + DiagnosticsSensor(device, "Timeout", entry.data), + ] + 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) + + async def async_update(self): + """ Retreive latest state. """ + _LOGGER.debug("[DIAG SENSOR] call async_update") + + @property + def icon(self) -> str | None: + return "mdi:monitor" + + @property + def should_poll(self) -> bool: + """ Do not poll for those entities """ + return False + +class TestVBEElapsedSecondEntity(SensorEntity): + """ La classe de l'entité TestVBE_4 """ + + def __init__(self, + hass: HomeAssistant, #pylint: disable=unused-argument + entry_infos, #pylint: disable=unused-argument + ) -> None: + """ Class constructor """ + self._attr_name = entry_infos.get("name") + self._attr_unique_id = entry_infos.get("entity_id") + self._attr_has_entity_name = True + self._attr_native_value = 36 + + @property + def icon(self) -> str | None: + return "mdi:timer-play" + + @property + def device_class(self) -> SensorDeviceClass | None: + return SensorDeviceClass.DURATION + + @property + def state_class(self) -> SensorStateClass | None: + return SensorStateClass.MEASUREMENT + + @property + def native_unit_of_measurement(self) -> str | None: + return UnitOfTime.SECONDS + + @property + def should_poll(self) -> bool: + """ Do not poll for those entities """ + return False diff --git a/custom_components/strings.json b/custom_components/strings.json new file mode 100644 index 0000000..aa2858d --- /dev/null +++ b/custom_components/strings.json @@ -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", + "DiscoverArea": "Area discovery" + } + }, + "areas": { + "title": "Configuration d'une zone", + "description": "Information sur la zone à configurer", + "data": { + "Name": "Name", + "Zone_id": "Zone_id", + "Other_area": "Ajouter une nouvelle zone" + } + } + }, + "error": { + "cannot_connect": "Cannot connected to Koolnova system", + "area_not_registered": "Area is not registered to the Koolnova system", + "area_already_configured": "Area is already configured", + "zone_id_error": "Zone Id must an integer between 1 and 16" + } + } +} \ No newline at end of file diff --git a/custom_components/switch.py b/custom_components/switch.py new file mode 100644 index 0000000..b1d66ab --- /dev/null +++ b/custom_components/switch.py @@ -0,0 +1,87 @@ +""" for switch component """ +# pylint: disable = too-few-public-methods +from __future__ import annotations +import logging + +from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.components.switch import SwitchEntity +from homeassistant.util import Throttle + +from .const import ( + DOMAIN, + MIN_TIME_BETWEEN_UPDATES, +) +from homeassistant.const import UnitOfTime +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""" + + for device in hass.data[DOMAIN]: + _LOGGER.debug("Device: {}".format(device)) + entities = [ + SystemStateSwitch(device), + ] + async_add_entities(entities) + +class SystemStateSwitch(SwitchEntity): + """Select component to set system state """ + _attr_has_entity_name = True + + def __init__(self, + device: Koolnova, + ) -> None: + super().__init__() + self._device = device + self._attr_name = f"{device.name} system state" + self._attr_device_info = device.device_info + self._attr_unique_id = f"{DOMAIN}-SystemState-switch" + _LOGGER.debug("[SYS STATE] State: {}".format(bool(int(self._device.sys_state)))) + self._is_on = bool(int(self._device.sys_state)) + + async def async_turn_on(self, **kwargs): + """Turn the entity on.""" + _LOGGER.debug("[SYS STATE] turn on") + self._is_on = True + await self._device.set_sys_state(SysState.SYS_STATE_ON) + + async def async_turn_off(self, **kwargs): + """Turn the entity off.""" + _LOGGER.debug("[SYS STATE] turn off") + self._is_on = False + await self._device.set_sys_state(SysState.SYS_STATE_OFF) + + def _update_state(self) -> None: + """ update system state """ + _LOGGER.debug("[SYS STATE] _update_state") + self._is_on = bool(int(self._device.sys_state)) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("[SYS STATE] async_update") + self._update_state() + + @property + def is_on(self): + """If the switch is currently on or off.""" + return self._is_on + + @property + def icon(self) -> str | None: + """Icon of the entity.""" + return "mdi:power" + + @property + def should_poll(self) -> bool: + """ Do not poll for those entities """ + return False \ No newline at end of file diff --git a/custom_components/translations/fr.json b/custom_components/translations/fr.json new file mode 100644 index 0000000..e8a0f4c --- /dev/null +++ b/custom_components/translations/fr.json @@ -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", + "DiscoverArea": "Type de découverte de zones" + } + }, + "areas": { + "title": "Configuration d'une zone", + "description": "Information sur la zone à configurer", + "data": { + "Name": "Nom de la zone", + "Zone_id": "Zone_id", + "Other_area": "Ajouter une nouvelle zone" + } + } + }, + "error": { + "cannot_connect": "Cannot connected to Koolnova system", + "area_not_registered": "Area is not registered to the Koolnova system", + "area_already_configured": "Area is already configured", + "zone_id_error": "Zone Id must an integer between 1 and 16" + } + } +} \ No newline at end of file