add domain folder

This commit is contained in:
2024-02-29 11:33:00 +01:00
parent 27596eecd9
commit 0d2aba4b18
15 changed files with 1 additions and 1 deletions
@@ -0,0 +1,149 @@
# Constants for koolnova Modbus RTU BMS
# d'après la documentation officielle A52102 - Registre contrôle Modbus
from enum import Enum
# La couche physique est Modbus RTU sur RS485 à 9600, avec 8 bits de données, sans parité
# ou même parité et un bit d'arrêt. Par défaut: 9600 8E1
# L'adresse Modbus par défaut est 49
DEFAULT_ADDR = 49
DEFAULT_BAUDRATE = 9600
DEFAULT_PARITY = 'E'
DEFAULT_STOPBITS = 1
DEFAULT_BYTESIZE = 8
# Nombre de machines (AC1, AC2, AC3 et AC4)
NUM_OF_ENGINES = 4
# Nombre de registres par zone
NUM_REG_PER_ZONE = 4
# Nombre max de zone pour un systeme
NB_ZONE_MAX = 16
# Chaque zone climatique est définie par 4 registres et il y a 16 zones possibles,
# donc le climat est défini par 64 registres
REG_START_ZONE = 0
# Commandes Modbus prises en charge sont le Read Holding Register (0x03)
# et le Write Single Register (0x06)
REG_LOCK_ZONE = 0 # 40001, 40005, 40009, etc ...
REG_STATE_AND_FLOW = 1 # 40002, 40006, 40010, etc ...
class ZoneState(Enum):
STATE_OFF = 0
STATE_ON = 1
def __int__(self):
return self.value
class ZoneRegister(Enum):
REGISTER_OFF = 0
REGISTER_ON = 1
def __int__(self):
return self.value
class ZoneFanMode(Enum):
FAN_OFF = 0
FAN_LOW = 1
FAN_MEDIUM = 2
FAN_HIGH = 3
FAN_AUTO = 4
def __int__(self):
return self.value
class ZoneClimMode(Enum):
OFF = 0
COOL = 1
HEAT = 2
HEATING_FLOOR = 4
REFRESHING_FLOOR = 5
HEATING_FLOOR_2 = 6
def __int__(self):
return self.value
# Température de consigne = (data / 2) => delta: 15°C -> 35°C
REG_TEMP_ORDER = 2 # 40003, 40007, 40011, etc ...
# Température réelle = (data / 2) => delta: 0°C -> 50°C
REG_TEMP_REAL = 3 # 40004, 40008, 40012, etc ...
# Temperature maximale de consigne : 35°C
MAX_TEMP_ORDER = 35.0
# Temperature minimale de consigne : 15°C
MIN_TEMP_ORDER = 15.0
# Pas de la temperature de consigne : 0.5°C
STEP_TEMP_ORDER = 0.5
# Temperature maximale : 50°C
MAX_TEMP = 50.0
# Temperature minimale : 0°C
MIN_TEMP = 0.0
# Pas de la temperature : 0.5°C
STEP_TEMP = 0.5
# (4 registres: 64 -> 67) Debit des machines (0: arret -> 15: debit maximum)
REG_START_FLOW_ENGINE = 64
NUM_REG_FLOG_ENGINE = 4
FLOW_ENGINE_VAL_MAX = 15
FLOW_ENGINE_VAL_MIN = 0
# (4 registres : 68 -> 71) Température de consigne de la machine.
# Valeur décimale de 30 à 60 = double de la temp de consigne des consignes AC1, AC2, AC3 et AC4
REG_START_ORDER_TEMP = 68
NUM_REG_ORDER_TEMP = 4
# (4 registres : 72 -> 75) Programmation des débit des machines du système
REG_START_FLOW_STATE_ENGINE = 72
NUM_REG_FLOW_STATE_ENGINE = 4
class FlowEngine(Enum):
MANUAL_MIN = 1
MANUAL_MED = 2
MANUAL_HIGH = 3
AUTO = 4
def __int__(self):
return self.value
# Communication Modbus
REG_COMM = 76
REG_ADDR_MODBUS = 77 # dispo (1 - 127)
REG_EFFICIENCY = 78
# Point d'equilibre entre efficience/vitesse du système de zone
# chiffre élevé = meilleur efficience
# chiffre bas = temp réglée atteinte au plus tot
class Efficiency(Enum):
LOWER_EFF = 1
LOW_EFF = 2
MED_EFF = 3
HIGH_EFF = 4
HIGHER_EFF = 5
def __int__(self):
return self.value
REG_CLIM_ID = 79
REG_SYS_STATE = 80
class SysState(Enum):
SYS_STATE_OFF = 0
SYS_STATE_ON = 1
def __int__(self):
return self.value
REG_GLOBAL_MODE = 81
class GlobalMode(Enum):
COLD = 1
HEAT = 2
HEATING_FLOOR = 4
REFRESHING_FLOOR = 5
HEATING_FLOOR_2 = 6
def __int__(self):
return self.value
@@ -0,0 +1,697 @@
""" local API to manage system, engines and areas """
import re, sys, os
import logging as log
import asyncio
from homeassistant.helpers.entity import DeviceInfo
from ..const import DOMAIN
from . import const
from .operations import Operations, ModbusConnexionError
_LOGGER = log.getLogger(__name__)
class Area:
''' koolnova Area class '''
def __init__(self,
name:str = "",
id_zone:int = 0,
state:const.ZoneState = const.ZoneState.STATE_OFF,
register:const.ZoneRegister = const.ZoneRegister.REGISTER_OFF,
fan_mode:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF,
clim_mode:const.ZoneClimMode = const.ZoneClimMode.OFF,
real_temp:float = 0,
order_temp:float = 0
) -> None:
''' Class constructor '''
self._name = name
self._id = id_zone
self._state = state
self._register = register
self._fan_mode = fan_mode
self._clim_mode = clim_mode
self._real_temp = real_temp
self._order_temp = order_temp
@property
def name(self) -> str:
''' Get area name '''
return self._name
@name.setter
def name(self, name:str) -> None:
''' Set area name '''
if not isinstance(name, str):
raise AssertionError('Input variable must be a string')
self._name = name
@property
def id_zone(self) -> int:
''' Get area id '''
return self._id
@property
def state(self) -> const.ZoneState:
''' Get state '''
return self._state
@state.setter
def state(self, val:const.ZoneState) -> None:
''' Set state '''
if not isinstance(val, const.ZoneState):
raise AssertionError('Input variable must be Enum ZoneState')
self._state = val
@property
def register(self) -> const.ZoneRegister:
''' Get register state '''
return self._register
@register.setter
def register(self, val:const.ZoneRegister) -> None:
''' Set register state '''
if not isinstance(val, const.ZoneRegister):
raise AssertionError('Input variable must be Enum ZoneRegister')
self._register = val
@property
def fan_mode(self) -> const.ZoneFanMode:
''' Get Fan Mode '''
return self._fan_mode
@fan_mode.setter
def fan_mode(self, val:const.ZoneFanMode) -> None:
''' Set Fan Mode '''
if not isinstance(val, const.ZoneFanMode):
raise AssertionError('Input variable must be Enum ZoneFanMode')
self._fan_mode = val
@property
def clim_mode(self) -> const.ZoneClimMode:
''' Get Clim Mode '''
return self._clim_mode
@clim_mode.setter
def clim_mode(self, val:const.ZoneClimMode) -> None:
''' Set Clim Mode '''
if not isinstance(val, const.ZoneClimMode):
raise AssertionError('Input variable must be Enum ZoneClimMode')
self._clim_mode = val
@property
def real_temp(self) -> float:
''' Get real temp '''
return self._real_temp
@real_temp.setter
def real_temp(self, val:float) -> None:
''' Set Real Temp '''
if not isinstance(val, float):
raise AssertionError('Input variable must be Float')
self._real_temp = val
@property
def order_temp(self) -> float:
''' Get order temp '''
return self._order_temp
@order_temp.setter
def order_temp(self, val:float) -> None:
''' Set Order Temp '''
if not isinstance(val, float):
raise AssertionError('Input variable must be float')
if val > const.MAX_TEMP_ORDER or val < const.MIN_TEMP_ORDER:
raise OrderTempError('Order temp value must be between {} and {}'.format(const.MIN_TEMP_ORDER, const.MAX_TEMP_ORDER))
self._order_temp = val
def __repr__(self) -> str:
''' repr method '''
return repr('Area(Name: {}, Id:{}, State:{}, Register:{}, Fan:{}, Clim:{}, Real Temp:{}, Order Temp:{})'.format(
self._name,
self._id,
self._state,
self._register,
self._fan_mode,
self._clim_mode,
self._real_temp,
self._order_temp))
class Engine:
''' koolnova Engine class '''
def __init__(self,
engine_id:int = 0,
throughput:int = 0,
state:const.FlowEngine = const.FlowEngine.AUTO,
order_temp:float = 0
) -> None:
''' Constructor class '''
self._engine_id = engine_id
self._throughput = throughput
self._state = state
self._order_temp = order_temp
@property
def engine_id(self) -> int:
''' Get Engine ID '''
return self._engine_id
@engine_id.setter
def engine_id(self, val:int) -> None:
''' Set Engine ID '''
if not isinstance(val, int):
raise AssertionError('Input variable must be Int')
if val > const.NUM_OF_ENGINES:
raise NumUnitError('Engine ID must be lower than {}'.format(const.NUM_OF_ENGINES))
self._engine_id = val
@property
def throughput(self) -> int:
''' Get throughput Engine '''
return self._throughput
@throughput.setter
def throughput(self, val:int) -> None:
''' Set throughput Engine '''
if not isinstance(val, int):
raise AssertionError('Input variable must be Int')
if val > const.FLOW_ENGINE_VAL_MAX or val < const.FLOW_ENGINE_VAL_MIN:
raise FlowEngineError('throughput engine value ({}) must be between {} and {}'.format(val,
const.FLOW_ENGINE_VAL_MIN,
const.FLOW_ENGINE_VAL_MAX))
self._throughput = val
@property
def state(self) -> const.FlowEngine:
''' Get Engine state '''
return self._state
@state.setter
def state(self, val:const.FlowEngine) -> None:
''' Set Engine state '''
if not isinstance(val, const.FlowEngine):
raise AssertionError('Input variable must be Enum FlowEngine')
self._state = val
@property
def order_temp(self) -> float:
''' Get Order Temp '''
return self._order_temp
@order_temp.setter
def order_temp(self, val:float = 0.0) -> None:
''' Set order temp engine '''
if not isinstance(val, float):
raise AssertionError('Input variable must be Int')
if val > 0 and (val > 30.0 or val < 15.0):
raise OrderTempError('Flow Engine value ({}) must be between 15 and 30'.format(val))
self._order_temp = val
def __repr__(self) -> str:
''' repr method '''
return repr('Unit(Id:{}, Throughput:{}, State:{}, Order Temp:{})'.format(self._engine_id,
self._throughput,
self._state,
self._order_temp))
class Koolnova:
''' koolnova Device class '''
def __init__(self,
name:str = "",
port:str = "",
addr:int = const.DEFAULT_ADDR,
baudrate:int = const.DEFAULT_BAUDRATE,
parity:str = const.DEFAULT_PARITY,
bytesize:int = const.DEFAULT_BYTESIZE,
stopbits:int = const.DEFAULT_STOPBITS,
timeout:int = 1) -> None:
''' Class constructor '''
self._client = Operations(port=port,
addr=addr,
baudrate=baudrate,
parity=parity,
bytesize=bytesize,
stopbits=stopbits,
timeout=timeout)
self._name = name
self._global_mode = const.GlobalMode.COLD
self._efficiency = const.Efficiency.LOWER_EFF
self._sys_state = const.SysState.SYS_STATE_OFF
self._engines = []
self._areas = []
def _area_defined(self,
id_search:int = 0,
) -> (bool, int):
""" test if area id is defined """
_areas_found = [idx for idx, x in enumerate(self._areas) if x.id_zone == id_search]
_idx = 0
if not _areas_found:
_LOGGER.error("Area id ({}) not defined".format(id_search))
return False, _idx
elif len(_areas_found) > 1:
_LOGGER.error("Multiple Area with same id ({})".format(id_search))
return False, _idx
else:
_idx = _areas_found[0]
_LOGGER.debug("idx found: {}".format(_idx))
return True, _idx
async def async_update(self) -> bool:
''' update values from modbus '''
_LOGGER.debug("Retreive system status ...")
ret, self._sys_state = await self._client.async_system_status()
if not ret:
_LOGGER.error("Error retreiving system status")
self._sys_state = const.SysState.SYS_STATE_OFF
_LOGGER.debug("Retreive global mode ...")
ret, self._global_mode = await self._client.async_global_mode()
if not ret:
_LOGGER.error("Error retreiving global mode")
self._global_mode = const.GlobalMode.COLD
_LOGGER.debug("Retreive efficiency ...")
ret, self._efficiency = await self._client.async_efficiency()
if not ret:
_LOGGER.error("Error retreiving efficiency")
self._efficiency = const.Efficiency.LOWER_EFF
await asyncio.sleep(0.1)
_LOGGER.debug("Retreive engines ...")
for idx in range(1, const.NUM_OF_ENGINES + 1):
engine = Engine(engine_id = idx)
ret, engine.throughput = await self._client.async_engine_throughput(engine_id = idx)
ret, engine.state = await self._client.async_engine_state(engine_id = idx)
ret, engine.order_temp = await self._client.async_engine_order_temp(engine_id = idx)
self._engines.append(engine)
await asyncio.sleep(0.1)
return True
async def async_connect(self) -> bool:
''' connect to the modbus serial server '''
ret = True
await self._client.async_connect()
if not self.connected():
ret = False
raise ClientNotConnectedError("Client Modbus connexion error")
#_LOGGER.info("Update system values")
#ret = await self.update()
return ret
def connected(self) -> bool:
''' get modbus client status '''
return self._client.connected
def disconnect(self) -> None:
''' close the underlying socket connection '''
self._client.disconnect()
async def async_discover_areas(self) -> None:
''' Set all registered areas for system '''
if not self._client.connected:
raise ModbusConnexionError('Client Modbus not connected')
zones_lst = await self._client.async_discover_registered_areas()
for zone in zones_lst:
self._areas.append(Area(name = zone['name'],
id_zone = zone['id'],
state = zone['state'],
register = zone['register'],
fan_mode = zone['fan'],
clim_mode = zone['clim'],
real_temp = zone['real_temp'],
order_temp = zone['order_temp']
))
return
async def async_add_manual_registered_area(self,
name:str = "",
id_zone:int = 0) -> bool:
''' Add manual area to koolnova system '''
if not self._client.connected:
raise ModbusConnexionError('Client Modbus not connected')
ret, zone_dict = await self._client.async_area_registered(zone_id = id_zone)
if not ret:
_LOGGER.error("Zone with ID: {} is not registered".format(id_zone))
return False
for zone in self._areas:
if id_zone == zone.id_zone:
_LOGGER.error('Zone registered with ID: {} is already saved')
return False
self._areas.append(Area(name = name,
id_zone = id_zone,
state = zone_dict['state'],
register = zone_dict['register'],
fan_mode = zone_dict['fan'],
clim_mode = zone_dict['clim'],
real_temp = zone_dict['real_temp'],
order_temp = zone_dict['order_temp']
))
_LOGGER.debug("Areas registered: {}".format(self._areas))
return True
@property
def areas(self) -> list:
''' get areas '''
return self._areas
def get_area(self, zone_id:int = 0) -> Area:
''' get specific area '''
return self._areas[zone_id - 1]
async def async_update_area(self, zone_id:int = 0) -> bool:
""" update specific area from zone_id """
ret, infos = await self._client.async_area_registered(zone_id = zone_id)
if not ret:
_LOGGER.error("Error retreiving area ({}) values".format(zone_id))
return ret, None
for idx, area in enumerate(self._areas):
if area.id_zone == zone_id:
# update areas list values from modbus response
self._areas[idx].state = infos['state']
self._areas[idx].register = infos['register']
self._areas[idx].fan_mode = infos['fan']
self._areas[idx].clim_mode = infos['clim']
self._areas[idx].real_temp = infos['real_temp']
self._areas[idx].order_temp = infos['order_temp']
break
return ret, self._areas[zone_id - 1]
async def async_update_all_areas(self) -> list:
""" update all areas registered and all engines values """
##### Areas
_ret, _vals = await self._client.async_areas_registered()
if not _ret:
_LOGGER.error("Error retreiving areas values")
return None
else:
for k,v in _vals.items():
for _idx, _area in enumerate(self._areas):
if k == _area.id_zone:
# update areas list values from modbus response
self._areas[_idx].state = v['state']
self._areas[_idx].register = v['register']
self._areas[_idx].fan_mode = v['fan']
self._areas[_idx].clim_mode = v['clim']
self._areas[_idx].real_temp = v['real_temp']
self._areas[_idx].order_temp = v['order_temp']
##### Engines
for _idx in range(1, const.NUM_OF_ENGINES + 1):
ret, self._engines[_idx - 1].throughput = await self._client.async_engine_throughput(engine_id = _idx)
ret, self._engines[_idx - 1].state = await self._client.async_engine_state(engine_id = _idx)
ret, self._engines[_idx - 1].order_temp = await self._client.async_engine_order_temp(engine_id = _idx)
##### Global mode
ret, self._global_mode = await self._client.async_global_mode()
if not ret:
_LOGGER.error("Error retreiving global mode")
self._global_mode = const.GlobalMode.COLD
##### Efficiency
ret, self._efficiency = await self._client.async_efficiency()
if not ret:
_LOGGER.error("Error retreiving efficiency")
self._efficiency = const.Efficiency.LOWER_EFF
##### Sys state
ret, self._sys_state = await self._client.async_system_status()
if not ret:
_LOGGER.error("Error retreiving system status")
self._sys_state = const.SysState.SYS_STATE_OFF
return {"areas": self._areas,
"engines": self._engines,
"glob": self._global_mode,
"eff": self._efficiency,
"sys": self._sys_state}
@property
def engines(self) -> list:
''' get engines '''
return self._engines
@property
def device_info(self) -> DeviceInfo:
""" Return a device description for device registry """
return {
"name": self._name,
"manufacturer": "Koolnova",
"identifiers": {(DOMAIN, "deadbeef")},
}
@property
def name(self) -> str:
''' Get name '''
return self._name
async def async_set_engine_state(self,
val:const.FlowEngine,
engine_id: int,
) -> None:
''' set engine flow from id '''
_LOGGER.debug("set engine (id:{}) flow : {}".format(engine_id, val))
if not isinstance(val, const.FlowEngine):
raise AssertionError('Input variable must be Enum FlowEngine')
ret = await self._client.async_set_engine_state(engine_id, val)
if not ret:
_LOGGER.error("[GLOBAL] Error writing {} to modbus".format(val))
raise UpdateValueError('Error writing to modbus updated value')
self._engines[engine_id - 1].state = val
@property
def global_mode(self) -> const.GlobalMode:
''' Get Global Mode '''
return self._global_mode
async def async_set_global_mode(self,
val:const.GlobalMode,
) -> None:
''' Set Global Mode '''
_LOGGER.debug("set global mode : {}".format(val))
if not isinstance(val, const.GlobalMode):
raise AssertionError('Input variable must be Enum GlobalMode')
ret = await self._client.async_set_global_mode(val)
if not ret:
_LOGGER.error("[GLOBAL] Error writing {} to modbus".format(val))
raise UpdateValueError('Error writing to modbus updated value')
self._global_mode = val
@property
def efficiency(self) -> const.Efficiency:
''' Get Efficiency '''
return self._efficiency
async def async_set_efficiency(self,
val:const.Efficiency,
) -> None:
''' Set Efficiency '''
_LOGGER.debug("set efficiency : {}".format(val))
if not isinstance(val, const.Efficiency):
raise AssertionError('Input variable must be Enum Efficiency')
ret = await self._client.async_set_efficiency(val)
if not ret:
_LOGGER.error("[EFF] Error writing {} to modbus".format(val))
raise UpdateValueError('Error writing to modbus updated value')
self._efficiency = val
@property
def sys_state(self) -> const.SysState:
''' Get System State '''
return self._sys_state
async def async_set_sys_state(self,
val:const.SysState,
) -> None:
''' Set System State '''
if not isinstance(val, const.SysState):
raise AssertionError('Input variable must be Enum SysState')
_LOGGER.debug("set system state : {}".format(val))
ret = await self._client.async_set_system_status(val)
if not ret:
_LOGGER.error("[SYS_STATE] Error writing {} to modbus".format(val))
raise UpdateValueError('Error writing to modbus updated value')
self._sys_state = val
async def async_get_area_temp(self,
zone_id:int,
) -> float:
""" get current temp of specific Area """
_ret, _idx = self._area_defined(id_search = zone_id)
if not _ret:
_LOGGER.error("Area not defined ...")
return False
ret, temp = await self._client.async_area_temp(id_zone = zone_id)
if not ret:
_LOGGER.error("Error reading temp for area with ID: {}".format(zone_id))
return False
self._areas[_idx].real_temp = temp
return temp
async def async_set_area_target_temp(self,
zone_id:int,
temp:float,
) -> bool:
""" set target temp of specific area """
_ret, _idx = self._area_defined(id_search = zone_id)
if not _ret:
_LOGGER.error("Area not defined ...")
return False
ret = await self._client.async_set_area_target_temp(zone_id = zone_id, val = temp)
if not ret:
_LOGGER.error("Error writing target temp for area with ID: {}".format(zone_id))
return False
self._areas[_idx].order_temp = temp
return True
async def async_get_area_target_temp(self,
zone_id:int,
) -> float:
""" get target temp of specific area """
_ret, _idx = self._area_defined(id_search = zone_id)
if not _ret:
_LOGGER.error("Area not defined ...")
return False
ret, temp = await self._client.async_area_target_temp(id_zone = zone_id)
if not ret:
_LOGGER.error("Error reading target temp for area with ID: {}".format(zone_id))
return 0.0
self._areas[_idx].order_temp = temp
return temp
async def async_set_area_clim_mode(self,
zone_id:int,
mode:const.ZoneClimMode,
) -> bool:
""" set climate mode for specific area """
_ret, _idx = self._area_defined(id_search = zone_id)
if not _ret:
_LOGGER.error("Area not defined ...")
return False
if mode == const.ZoneClimMode.OFF:
_LOGGER.debug("Set area state to OFF")
ret = await self._client.async_set_area_state(id_zone = zone_id, val = const.ZoneState.STATE_OFF)
if not ret:
_LOGGER.error("Error writing area state for area with ID: {}".format(zone_id))
return False
self._areas[_idx].state = const.ZoneState.STATE_OFF
else:
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
_LOGGER.debug("Set area state to ON")
# update area state
ret = await self._client.async_set_area_state(id_zone = zone_id, val = const.ZoneState.STATE_ON)
if not ret:
_LOGGER.error("Error writing area state for area with ID: {}".format(zone_id))
return False
_LOGGER.debug("clim mode ? {}".format(mode))
# update clim mode
ret = await self._client.async_set_area_clim_mode(id_zone = zone_id, val = mode)
if not ret:
_LOGGER.error("Error writing climate mode for area with ID: {}".format(zone_id))
return False
self._areas[_idx].clim_mode = mode
return True
async def async_set_area_fan_mode(self,
zone_id:int,
mode:const.ZoneFanMode,
) -> bool:
""" set fan mode for specific area """
# test if area id is defined
_ret, _idx = self._area_defined(id_search = zone_id)
if not _ret:
_LOGGER.error("Area not defined ...")
return False
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
_LOGGER.warning("Area state is off, cannot change fan speed ...")
return False
else:
_LOGGER.debug("fan mode ? {}".format(mode))
# writing new value to modbus
ret = await self._client.async_set_area_fan_mode(id_zone = zone_id, val = mode)
if not ret:
_LOGGER.error("Error writing fan mode for area with ID: {}".format(zone_id))
return False
# update fan mode in list for specific area
self._areas[_idx].fan_mode = mode
return True
def __repr__(self) -> str:
''' repr method '''
return repr('System(Global Mode:{}, Efficiency:{}, State:{})'.format(
self._global_mode,
self._efficiency,
self._sys_state))
class NumUnitError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class FlowEngineError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class OrderTempError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class ClientNotConnectedError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class UpdateValueError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
@@ -0,0 +1,543 @@
""" local API to communicate with Koolnova BMS Modbus RTU client """
import re, sys, os
import logging as log
import asyncio
from pymodbus import pymodbus_apply_logging_config
from pymodbus.client import AsyncModbusSerialClient as ModbusClient
from pymodbus.exceptions import ModbusException
from pymodbus.pdu import ExceptionResponse
from pymodbus.transaction import ModbusRtuFramer
from . import const
_LOGGER = log.getLogger(__name__)
class Operations:
''' koolnova BMS Modbus operations class '''
def __init__(self, port:str, timeout:int, debug:bool=False) -> None:
''' Class constructor '''
self._port = port
self._timeout = timeout
self._addr = const.DEFAULT_ADDR
self._baudrate = const.DEFAULT_BAUDRATE
self._parity = const.DEFAULT_PARITY
self._bytesize = const.DEFAULT_BYTESIZE
self._stopbits = const.DEFAULT_STOPBITS
self._client = ModbusClient(port=self._port,
baudrate=self._baudrate,
parity=self._parity,
stopbits=self._stopbits,
bytesize=self._bytesize,
timeout=self._timeout)
if debug:
pymodbus_apply_logging_config("DEBUG")
def __init__(self,
port:str="",
addr:int=const.DEFAULT_ADDR,
baudrate:int=const.DEFAULT_BAUDRATE,
parity:str=const.DEFAULT_PARITY,
stopbits:int=const.DEFAULT_STOPBITS,
bytesize:int=const.DEFAULT_BYTESIZE,
timeout:int=1,
debug:bool=False) -> None:
''' Class constructor '''
self._port = port
self._addr = addr
self._timeout = timeout
self._baudrate = baudrate
self._parity = parity
self._bytesize = bytesize
self._stopbits = stopbits
self._client = ModbusClient(port=self._port,
baudrate=self._baudrate,
parity=self._parity,
stopbits=self._stopbits,
bytesize=self._bytesize,
timeout=self._timeout)
if debug:
pymodbus_apply_logging_config("DEBUG")
async def __async_read_register(self, reg:int) -> (int, bool):
''' Read one holding register (code 0x03) '''
rr = None
if not self._client.connected:
raise ModbusConnexionError('Client Modbus not connected')
try:
_LOGGER.debug("reading holding register: {} - Addr: {}".format(hex(reg), self._addr))
rr = await self._client.read_holding_registers(address=reg, count=1, slave=self._addr)
if rr.isError():
_LOGGER.error("reading holding register error")
return None, False
except Exception as e:
_LOGGER.error("Modbus Error: {}".format(e))
return None, False
if isinstance(rr, ExceptionResponse):
_LOGGER.error("Received modbus exception ({})".format(rr))
return None, False
elif not rr:
_LOGGER.error("Response Null")
return None, False
return rr.registers[0], True
async def __async_read_registers(self, start_reg:int, count:int) -> (int, bool):
''' Read holding registers (code 0x03) '''
rr = None
if not self._client.connected:
raise ModbusConnexionError('Client Modbus not connected')
try:
rr = await self._client.read_holding_registers(address=start_reg, count=count, slave=self._addr)
if rr.isError():
_LOGGER.error("reading holding registers error")
return None, False
except Exception as e:
_LOGGER.error("{}".format(e))
return None, False
if isinstance(rr, ExceptionResponse):
_LOGGER.error("Received modbus exception ({})".format(rr))
return None, False
elif not rr:
_LOGGER.error("Response Null")
return None, False
return rr.registers, True
async def __async_write_register(self, reg:int, val:int) -> bool:
''' Write one register (code 0x06) '''
rq = None
ret = True
if not self._client.connected:
raise ModbusConnexionError('Client Modbus not connected')
try:
_LOGGER.debug("writing single register: {} - Addr: {} - Val: {}".format(hex(reg), self._addr, hex(val)))
rq = await self._client.write_register(address=reg, value=val, slave=self._addr)
if rq.isError():
_LOGGER.error("writing register error")
return False
except Exception as e:
_LOGGER.error("{}".format(e))
return False
if isinstance(rq, ExceptionResponse):
_LOGGER.error("Received modbus exception ({})".format(rr))
return False
return ret
async def async_connect(self) -> None:
''' connect to the modbus serial server '''
await self._client.connect()
def connected(self) -> bool:
''' get modbus client status '''
return self._client.connected
def disconnect(self) -> None:
''' close the underlying socket connection '''
if self._client.connected:
self._client.close()
async def async_discover_registered_areas(self) -> list:
''' Discover all areas registered to the system '''
regs, ret = await self.__async_read_registers(start_reg=const.REG_START_ZONE,
count=const.NB_ZONE_MAX * const.NUM_REG_PER_ZONE)
if not ret:
raise ReadRegistersError("Read holding regsiter error")
zones_lst = []
zone_dict = {}
jdx = 1
flag = False
for idx, reg in enumerate(regs):
if idx % const.NUM_REG_PER_ZONE == 0:
zone_dict = {}
if const.ZoneRegister(reg >> 1) == const.ZoneRegister.REGISTER_ON:
zone_dict['id'] = jdx
zone_dict['state'] = const.ZoneState(reg & 0b01)
zone_dict['register'] = const.ZoneRegister(reg >> 1)
flag = True
elif idx % const.NUM_REG_PER_ZONE == 1 and flag:
zone_dict['fan'] = const.ZoneFanMode((reg & 0xF0) >> 4)
zone_dict['clim'] = const.ZoneClimMode(reg & 0x0F)
elif idx % const.NUM_REG_PER_ZONE == 2 and flag:
zone_dict['order_temp'] = reg/2
elif idx % const.NUM_REG_PER_ZONE == 3 and flag:
zone_dict['real_temp'] = reg/2
jdx += 1
flag = False
zones_lst.append(zone_dict)
return zones_lst
async def async_area_registered(self,
zone_id:int = 0,
) -> (bool, dict):
''' Get Area status from id '''
#_LOGGER.debug("Area : {}".format(zone_id))
if zone_id > const.NB_ZONE_MAX or zone_id == 0:
raise ZoneIdError('Zone Id must be between 1 to {}'.format(const.NB_ZONE_MAX))
zone_dict = {}
regs, ret = await self.__async_read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)),
count = const.NUM_REG_PER_ZONE)
if not ret:
raise ReadRegistersError("Error reading holding register")
if const.ZoneRegister(regs[0] >> 1) == const.ZoneRegister.REGISTER_OFF:
_LOGGER.warning("Zone with id: {} is not registered".format(zone_id))
return False, {}
zone_dict['state'] = const.ZoneState(regs[0] & 0b01)
zone_dict['register'] = const.ZoneRegister(regs[0] >> 1)
zone_dict['fan'] = const.ZoneFanMode((regs[1] & 0xF0) >> 4)
zone_dict['clim'] = const.ZoneClimMode(regs[1] & 0x0F)
zone_dict['order_temp'] = regs[2]/2
zone_dict['real_temp'] = regs[3]/2
return True, zone_dict
async def async_areas_registered(self) -> (bool, dict):
""" Get all areas values """
_areas_dict:dict = {}
# retreive all areas (registered and unregistered)
regs, ret = await self.__async_read_registers(start_reg = const.REG_START_ZONE,
count = const.NUM_REG_PER_ZONE * const.NB_ZONE_MAX)
if not ret:
raise ReadRegistersError("Error reading holding register")
for area_idx in range(const.NB_ZONE_MAX):
_idx:int = 4 * area_idx
_area_dict:dict = {}
# test if area is registered or not
if const.ZoneRegister(regs[_idx + const.REG_LOCK_ZONE] >> 1) == const.ZoneRegister.REGISTER_OFF:
continue
_area_dict['state'] = const.ZoneState(regs[_idx + const.REG_LOCK_ZONE] & 0b01)
_area_dict['register'] = const.ZoneRegister(regs[_idx + const.REG_LOCK_ZONE] >> 1)
_area_dict['fan'] = const.ZoneFanMode((regs[_idx + const.REG_STATE_AND_FLOW] & 0xF0) >> 4)
_area_dict['clim'] = const.ZoneClimMode(regs[_idx + const.REG_STATE_AND_FLOW] & 0x0F)
_area_dict['order_temp'] = regs[_idx + const.REG_TEMP_ORDER]/2
_area_dict['real_temp'] = regs[_idx + const.REG_TEMP_REAL]/2
_areas_dict[area_idx + 1] = _area_dict
return True, _areas_dict
async def async_system_status(self) -> (bool, const.SysState):
''' Read system status register '''
reg, ret = await self.__async_read_register(const.REG_SYS_STATE)
if not ret:
_LOGGER.error('Error retreive system status')
reg = 0
return ret, const.SysState(reg)
async def async_set_system_status(self,
opt:const.SysState,
) -> bool:
''' Write system status '''
ret = await self.__async_write_register(reg = const.REG_SYS_STATE, val = int(opt))
if not ret:
_LOGGER.error('Error writing system status')
return ret
async def async_global_mode(self) -> (bool, const.GlobalMode):
''' Read global mode '''
reg, ret = await self.__async_read_register(const.REG_GLOBAL_MODE)
if not ret:
_LOGGER.error('Error retreive global mode')
reg = 0
return ret, const.GlobalMode(reg)
async def async_set_global_mode(self,
opt:const.GlobalMode,
) -> bool:
''' Write global mode '''
ret = await self.__async_write_register(reg = const.REG_GLOBAL_MODE, val = int(opt))
if not ret:
_LOGGER.error('Error writing global mode')
return ret
async def async_efficiency(self) -> (bool, const.Efficiency):
''' read efficiency/speed '''
reg, ret = await self.__async_read_register(const.REG_EFFICIENCY)
if not ret:
_LOGGER.error('Error retreive efficiency')
reg = 0
return ret, const.Efficiency(reg)
async def async_set_efficiency(self,
opt:const.GlobalMode,
) -> bool:
''' Write efficiency '''
ret = await self.__async_write_register(reg = const.REG_EFFICIENCY, val = int(opt))
if not ret:
_LOGGER.error('Error writing efficiency')
return ret
async def async_engines_throughput(self) -> (bool, list):
''' read engines throughput AC1, AC2, AC3, AC4 '''
engines_lst = []
regs, ret = await self.__async_read_registers(const.REG_START_FLOW_ENGINE,
const.NUM_OF_ENGINES)
if ret:
for idx, reg in enumerate(regs):
engines_lst.append(const.FlowEngine(reg))
else:
_LOGGER.error('Error retreive engines throughput')
return ret, engines_lst
async def async_engine_throughput(self,
engine_id:int = 0,
) -> (bool, int):
''' read engine throughput specified by id '''
if engine_id < 1 or engine_id > 4:
raise UnitIdError("engine Id must be between 1 and 4")
reg, ret = await self.__async_read_register(const.REG_START_FLOW_ENGINE + (engine_id - 1))
if not ret:
_LOGGER.error('Error retreive engine throughput for id:{}'.format(engine_id))
reg = 0
return ret, reg
async def async_engine_state(self,
engine_id:int = 0,
) -> (bool, const.FlowEngine):
''' read engine state specified by id '''
if engine_id < 1 or engine_id > 4:
raise UnitIdError("Engine id must be between 1 and 4")
reg, ret = await self.__async_read_register(const.REG_START_FLOW_STATE_ENGINE + (engine_id - 1))
if not ret:
_LOGGER.error('Error retreive engine state for id:{}'.format(engine_id))
reg = 0
return ret, const.FlowEngine(reg)
async def async_set_engine_state(self,
engine_id:int = 0,
opt:const.FlowEngine = const.FlowEngine.AUTO,
) -> bool:
''' write engine state specified by id '''
if engine_id < 1 or engine_id > 4:
raise UnitIdError("Engine id must be between 1 and 4")
reg, ret = await self.__async_write_register(reg = const.REG_START_FLOW_STATE_ENGINE + (engine_id - 1), val = int(opt))
if not ret:
_LOGGER.error('Error writing engine state for id:{}'.format(engine_id))
return ret
async def async_engine_order_temp(self,
engine_id:int = 0,
) -> (bool, float):
''' read engine order temperature specified by id '''
if engine_id < 1 or engine_id > 4:
raise UnitIdError("Engine id must be between 1 and 4")
reg, ret = await self.__async_read_register(const.REG_START_ORDER_TEMP + (engine_id - 1))
if not ret:
_LOGGER.error('Error retreive engine order temp for id:{}'.format(engine_id))
reg = 0
return ret, reg / 2
async def async_engine_orders_temp(self) -> (bool, list):
''' read orders temperature for engines : AC1, AC2, AC3, AC4 '''
engines_lst = []
regs, ret = await self.__async_read_registers(const.REG_START_ORDER_TEMP, const.NUM_OF_ENGINES)
if ret:
for idx, reg in enumerate(regs):
engines_lst.append(reg/2)
else:
_LOGGER.error('error reading engines order temp registers')
return ret, engines_lst
async def async_set_area_target_temp(self,
zone_id:int = 0,
val:float = 0.0,
) -> bool:
''' Set area target temperature '''
if zone_id > const.NB_ZONE_MAX or zone_id == 0:
raise ZoneIdError('Zone Id must be between 1 to 16')
if val > const.MAX_TEMP_ORDER or val < const.MIN_TEMP_ORDER:
_LOGGER.error('Order Temperature must be between {} and {}'.format(const.MIN_TEMP_ORDER, const.MAX_TEMP_ORDER))
return False
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (zone_id - 1)) + const.REG_TEMP_ORDER, val = int(val * 2))
if not ret:
_LOGGER.error('Error writing area order temperature')
return ret
async def async_area_temp(self,
id_zone:int = 0,
) -> (bool, float):
""" get temperature of specific area id """
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_TEMP_REAL)
if not ret:
_LOGGER.error('Error retreive area real temp')
reg = 0
return ret, reg / 2
async def async_area_target_temp(self,
id_zone:int = 0,
) -> (bool, float):
""" get target temperature of specific area id """
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_TEMP_ORDER)
if not ret:
_LOGGER.error('Error retreive area target temp')
reg = 0
return ret, reg / 2
async def async_area_clim_and_fan_mode(self,
id_zone:int = 0,
) -> (bool, const.ZoneFanMode, const.ZoneClimMode):
""" get climate and fan mode of specific area id """
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW)
if not ret:
_LOGGER.error('Error retreive area fan and climate values')
reg = 0
return ret, const.ZoneFanMode((reg & 0xF0) >> 4), const.ZoneClimMode(reg & 0x0F)
async def async_area_state_and_register(self,
id_zone:int = 0,
) -> (bool, const.ZoneRegister, const.ZoneState):
""" get area state and register """
reg, ret = await self.__async_read_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_LOCK_ZONE)
if not ret:
_LOGGER.error('Error retreive area register value')
reg = 0
return ret, const.ZoneRegister(reg >> 1), const.ZoneState(reg & 0b01)
async def async_set_area_state(self,
id_zone:int = 0,
val:const.ZoneState = const.ZoneState.STATE_OFF,
) -> bool:
""" set area state """
register:const.ZoneRegister = const.ZoneRegister.REGISTER_OFF
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
raise ZoneIdError('Area Id must be between 1 to 16')
# retreive values to combine the new state with register read
ret, register, _ = await self.async_area_state_and_register(id_zone = id_zone)
if not ret:
_LOGGER.error("Error reading state and register mode")
return ret
#_LOGGER.debug("register & state: {}".format(hex((int(register) << 1) | (int(val) & 0b01))))
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_LOCK_ZONE,
val = int(int(register) << 1) | (int(val) & 0b01))
if not ret:
_LOGGER.error('Error writing area state value')
return True
async def async_set_area_clim_mode(self,
id_zone:int = 0,
val:const.ZoneClimMode = const.ZoneClimMode.OFF,
) -> bool:
""" set area clim mode """
fan:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
raise ZoneIdError('Zone Id must be between 1 to 16')
# retreive values to combine the new climate mode with fan mode read
ret, fan, _ = await self.async_area_clim_and_fan_mode(id_zone = id_zone)
if not ret:
_LOGGER.error("Error reading fan and clim mode")
return ret
#_LOGGER.debug("Fan & Clim: {}".format(hex((int(fan) << 4) | (int(val) & 0x0F))))
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW,
val = int(int(fan) << 4) | (int(val) & 0x0F))
if not ret:
_LOGGER.error('Error writing area climate mode')
return ret
async def async_set_area_fan_mode(self,
id_zone:int = 0,
val:const.ZoneFanMode = const.ZoneFanMode.FAN_OFF,
) -> bool:
""" set area fan mode """
clim:const.ZoneClimMode = const.ZoneClimMode.OFF
if id_zone > const.NB_ZONE_MAX or id_zone == 0:
raise ZoneIdError('Zone Id must be between 1 to 16')
# retreive values to combine the new fan mode with climate mode read
ret, _, clim = await self.async_area_clim_and_fan_mode(id_zone = id_zone)
if not ret:
_LOGGER.error("Error reading fan and clim mode")
return ret
#_LOGGER.debug("Fan & Clim: {}".format(hex((int(val) << 4) | (int(clim) & 0x0F))))
ret = await self.__async_write_register(reg = const.REG_START_ZONE + (4 * (id_zone - 1)) + const.REG_STATE_AND_FLOW,
val = int(int(val) << 4) | (int(clim) & 0x0F))
if not ret:
_LOGGER.error('Error writing area fan mode')
return ret
@property
def port(self) -> str:
''' Get Port '''
return self._port
@property
def address(self) -> str:
''' Get address '''
return self._addr
@property
def baudrate(self) -> str:
''' Get baudrate '''
return self._baudrate
@property
def parity(self) -> str:
''' Get parity '''
return self._parity
@property
def bytesize(self) -> str:
''' Get bytesize '''
return self._bytesize
@property
def stopbits(self) -> str:
''' Get stopbits '''
return self._stopbits
@property
def timeout(self) -> int:
''' Get Timeout '''
return self._timeout
class ModbusConnexionError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class ReadRegistersError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class ZoneIdError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg
class UnitIdError(Exception):
''' user defined exception '''
def __init__(self,
msg:str = "") -> None:
''' Class Constructor '''
self._msg = msg
def __str__(self):
''' print the message '''
return self._msg