add sources to custom_components

This commit is contained in:
2023-12-04 10:24:13 +01:00
parent b2e49e9960
commit e6f2662a6c
27 changed files with 2129 additions and 1283 deletions

View File

@@ -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))

View File

@@ -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()

View File

@@ -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"

118
custom_components/const.py Normal file
View File

@@ -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,
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
# )

View File

@@ -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()

View File

@@ -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

View File

@@ -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",
}

View File

@@ -1,414 +0,0 @@
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
# @author: sinseman44 <vincent.benoit@benserv.fr>
# @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

View File

@@ -1,308 +0,0 @@
#!/usr/bin/env python3
# -*- encoding: utf-8 -*-
# @author: sinseman44 <vincent.benoit@benserv.fr>
# @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

View File

@@ -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"
}

View File

@@ -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()

View File

@@ -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"
}

134
custom_components/select.py Normal file
View File

@@ -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()

111
custom_components/sensor.py Normal file
View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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"
}
}
}