add coordinator for climate entities
This commit is contained in:
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.util import Throttle
|
from homeassistant.util import Throttle
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
@@ -11,6 +11,11 @@ from homeassistant.components.climate import (
|
|||||||
ClimateEntity,
|
ClimateEntity,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
)
|
)
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.components.climate.const import (
|
from homeassistant.components.climate.const import (
|
||||||
HVACMode,
|
HVACMode,
|
||||||
@@ -51,17 +56,38 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
|
|||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant,
|
async def async_setup_entry(hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: ConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback):
|
async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
"""Setup switch entries"""
|
"""Setup switch entries"""
|
||||||
|
|
||||||
entities = []
|
entities = []
|
||||||
|
|
||||||
for device in hass.data[DOMAIN]:
|
for device in hass.data[DOMAIN]:
|
||||||
|
coordinator = ClimateCoordinator(hass, device)
|
||||||
for area in device.areas:
|
for area in device.areas:
|
||||||
_LOGGER.debug("Device: {} - Area: {}".format(device, area))
|
_LOGGER.debug("Device: {} - Area: {}".format(device, area))
|
||||||
entities.append(AreaClimateEntity(device, area))
|
entities.append(AreaClimateEntity(coordinator, device, area))
|
||||||
async_add_entities(entities)
|
async_add_entities(entities)
|
||||||
|
|
||||||
class AreaClimateEntity(ClimateEntity):
|
class ClimateCoordinator(DataUpdateCoordinator):
|
||||||
|
""" Climate coordinator """
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
hass: HomeAssistant,
|
||||||
|
device: Koolnova,
|
||||||
|
) -> None:
|
||||||
|
""" Class constructor """
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
# Name of the data. For logging purposes.
|
||||||
|
name=DOMAIN,
|
||||||
|
update_method=device.update_all_areas,
|
||||||
|
# Polling interval. Will only be polled if there are subscribers.
|
||||||
|
update_interval=timedelta(seconds=30),
|
||||||
|
)
|
||||||
|
|
||||||
|
class AreaClimateEntity(CoordinatorEntity, ClimateEntity):
|
||||||
""" Reperesentation of a climate entity """
|
""" Reperesentation of a climate entity """
|
||||||
# pylint: disable = too-many-instance-attributes
|
# pylint: disable = too-many-instance-attributes
|
||||||
|
|
||||||
@@ -79,10 +105,12 @@ class AreaClimateEntity(ClimateEntity):
|
|||||||
_attr_target_temperature_step: float = STEP_TEMP_ORDER
|
_attr_target_temperature_step: float = STEP_TEMP_ORDER
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self,
|
||||||
|
coordinator: ClimateCoordinator, # pylint: disable=unused-argument
|
||||||
device: Koolnova, # pylint: disable=unused-argument
|
device: Koolnova, # pylint: disable=unused-argument
|
||||||
area: Area, # pylint: disable=unused-argument
|
area: Area, # pylint: disable=unused-argument
|
||||||
) -> None:
|
) -> None:
|
||||||
""" Class constructor """
|
""" Class constructor """
|
||||||
|
super().__init__(coordinator)
|
||||||
self._device = device
|
self._device = device
|
||||||
self._area = area
|
self._area = area
|
||||||
self._attr_name = f"{device.name} {area.name} area"
|
self._attr_name = f"{device.name} {area.name} area"
|
||||||
@@ -125,7 +153,9 @@ class AreaClimateEntity(ClimateEntity):
|
|||||||
break
|
break
|
||||||
ret = await self._device.set_area_fan_mode(zone_id = self._area.id_zone,
|
ret = await self._device.set_area_fan_mode(zone_id = self._area.id_zone,
|
||||||
mode = ZoneFanMode(opt))
|
mode = ZoneFanMode(opt))
|
||||||
await self._update_state()
|
if not ret:
|
||||||
|
_LOGGER.exception("Error setting new fan value for area id {}".format(self._area.id_zone))
|
||||||
|
await self.coordinator.async_request_refresh()
|
||||||
|
|
||||||
async def async_set_hvac_mode(self, hvac_mode:HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode:HVACMode) -> None:
|
||||||
""" set new target hvac mode """
|
""" set new target hvac mode """
|
||||||
@@ -137,39 +167,67 @@ class AreaClimateEntity(ClimateEntity):
|
|||||||
break
|
break
|
||||||
ret = await self._device.set_area_clim_mode(zone_id = self._area.id_zone,
|
ret = await self._device.set_area_clim_mode(zone_id = self._area.id_zone,
|
||||||
mode = ZoneClimMode(opt))
|
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:
|
if not ret:
|
||||||
_LOGGER.error("[Climate {}] Cannot update area values")
|
_LOGGER.exception("Error setting new hvac value for area id {}".format(self._area.id_zone))
|
||||||
return
|
await self.coordinator.async_request_refresh()
|
||||||
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)
|
@callback
|
||||||
async def async_update(self):
|
def _handle_coordinator_update(self) -> None:
|
||||||
""" Retreive latest values """
|
""" Handle updated data from the coordinator """
|
||||||
await self._update_state()
|
for _cur_area in self.coordinator.data:
|
||||||
|
if _cur_area.id_zone == self._area.id_zone:
|
||||||
|
_LOGGER.debug("[Climate {}] temp:{} - target:{} - state: {} - hvac:{} - fan:{}".format(_cur_area.id_zone,
|
||||||
|
_cur_area.real_temp,
|
||||||
|
_cur_area.order_temp,
|
||||||
|
_cur_area.state,
|
||||||
|
_cur_area.clim_mode,
|
||||||
|
_cur_area.fan_mode))
|
||||||
|
self._area = _cur_area
|
||||||
|
self._attr_current_temperature = _cur_area.real_temp
|
||||||
|
self._attr_target_temperature = _cur_area.order_temp
|
||||||
|
if _cur_area.state == ZoneState.STATE_OFF:
|
||||||
|
self._attr_hvac_mode = HVACMode.OFF
|
||||||
|
else:
|
||||||
|
self._attr_hvac_mode = HVAC_TRANSLATION[int(_cur_area.clim_mode)]
|
||||||
|
self._attr_fan_mode = FAN_TRANSLATION[int(_cur_area.fan_mode)]
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# @property
|
||||||
|
# def should_poll(self) -> bool:
|
||||||
|
# """ Do not poll for those entities """
|
||||||
|
# return False
|
||||||
@@ -128,7 +128,7 @@ class Area:
|
|||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
''' repr method '''
|
''' repr method '''
|
||||||
return repr('Zone(Name: {}, Id:{}, State:{}, Register:{}, Fan:{}, Clim:{}, Real Temp:{}, Order Temp:{})'.format(
|
return repr('Area(Name: {}, Id:{}, State:{}, Register:{}, Fan:{}, Clim:{}, Real Temp:{}, Order Temp:{})'.format(
|
||||||
self._name,
|
self._name,
|
||||||
self._id,
|
self._id,
|
||||||
self._state,
|
self._state,
|
||||||
@@ -169,7 +169,7 @@ class Koolnova:
|
|||||||
id_search:int = 0,
|
id_search:int = 0,
|
||||||
) -> (bool, int):
|
) -> (bool, int):
|
||||||
""" test if area id is defined """
|
""" test if area id is defined """
|
||||||
_areas_found = [idx for idx,x in enumerate(self._areas) if x.id_zone == id_search]
|
_areas_found = [idx for idx, x in enumerate(self._areas) if x.id_zone == id_search]
|
||||||
_idx = 0
|
_idx = 0
|
||||||
if not _areas_found:
|
if not _areas_found:
|
||||||
_LOGGER.error("Area id ({}) not defined".format(id_search))
|
_LOGGER.error("Area id ({}) not defined".format(id_search))
|
||||||
@@ -178,8 +178,8 @@ class Koolnova:
|
|||||||
_LOGGER.error("Multiple Area with same id ({})".format(id_search))
|
_LOGGER.error("Multiple Area with same id ({})".format(id_search))
|
||||||
return False, _idx
|
return False, _idx
|
||||||
else:
|
else:
|
||||||
_LOGGER.debug("idx found: {}".format(_idx))
|
|
||||||
_idx = _areas_found[0]
|
_idx = _areas_found[0]
|
||||||
|
_LOGGER.debug("idx found: {}".format(_idx))
|
||||||
return True, _idx
|
return True, _idx
|
||||||
|
|
||||||
async def update(self) -> bool:
|
async def update(self) -> bool:
|
||||||
@@ -292,7 +292,7 @@ class Koolnova:
|
|||||||
return self._areas[zone_id - 1]
|
return self._areas[zone_id - 1]
|
||||||
|
|
||||||
async def update_area(self, zone_id:int = 0) -> bool:
|
async def update_area(self, zone_id:int = 0) -> bool:
|
||||||
""" update area """
|
""" update specific area from zone_id """
|
||||||
ret, infos = await self._client.zone_registered(zone_id = zone_id)
|
ret, infos = await self._client.zone_registered(zone_id = zone_id)
|
||||||
if not ret:
|
if not ret:
|
||||||
_LOGGER.error("Error retreiving area ({}) values".format(zone_id))
|
_LOGGER.error("Error retreiving area ({}) values".format(zone_id))
|
||||||
@@ -309,6 +309,26 @@ class Koolnova:
|
|||||||
break
|
break
|
||||||
return ret, self._areas[zone_id - 1]
|
return ret, self._areas[zone_id - 1]
|
||||||
|
|
||||||
|
async def update_all_areas(self) -> list:
|
||||||
|
""" update all areas registered """
|
||||||
|
_ret, _vals = await self._client.areas_registered()
|
||||||
|
if not _ret:
|
||||||
|
_LOGGER.error("Error retreiving areas values")
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
_LOGGER.debug("areas: {}".format(_vals))
|
||||||
|
for k,v in _vals.items():
|
||||||
|
for _idx, _area in enumerate(self._areas):
|
||||||
|
if k == _area.id_zone:
|
||||||
|
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']
|
||||||
|
|
||||||
|
return self._areas
|
||||||
|
|
||||||
def get_units(self) -> list:
|
def get_units(self) -> list:
|
||||||
''' get units '''
|
''' get units '''
|
||||||
return self._units
|
return self._units
|
||||||
@@ -381,15 +401,16 @@ class Koolnova:
|
|||||||
zone_id:int,
|
zone_id:int,
|
||||||
) -> float:
|
) -> float:
|
||||||
""" get current temp of specific Area """
|
""" 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.area_temp(id_zone = zone_id)
|
ret, temp = await self._client.area_temp(id_zone = zone_id)
|
||||||
if not ret:
|
if not ret:
|
||||||
_LOGGER.error("Error reading temp for area with ID: {}".format(zone_id))
|
_LOGGER.error("Error reading temp for area with ID: {}".format(zone_id))
|
||||||
return False
|
return False
|
||||||
for idx, area in enumerate(self._areas):
|
self._areas[_idx].real_temp = temp
|
||||||
if area.id_zone == zone_id:
|
|
||||||
# update areas list value from modbus response
|
|
||||||
self._areas[idx].real_temp = temp
|
|
||||||
|
|
||||||
return temp
|
return temp
|
||||||
|
|
||||||
async def set_area_target_temp(self,
|
async def set_area_target_temp(self,
|
||||||
@@ -397,28 +418,32 @@ class Koolnova:
|
|||||||
temp:float,
|
temp:float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
""" set target temp of specific area """
|
""" 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.set_area_target_temp(zone_id = zone_id, val = temp)
|
ret = await self._client.set_area_target_temp(zone_id = zone_id, val = temp)
|
||||||
if not ret:
|
if not ret:
|
||||||
_LOGGER.error("Error writing target temp for area with ID: {}".format(zone_id))
|
_LOGGER.error("Error writing target temp for area with ID: {}".format(zone_id))
|
||||||
return False
|
return False
|
||||||
for idx, area in enumerate(self._areas):
|
self._areas[_idx].order_temp = temp
|
||||||
if area.id_zone == zone_id:
|
|
||||||
# update areas list value from modbus response
|
|
||||||
self._areas[idx].order_temp = temp
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def get_area_target_temp(self,
|
async def get_area_target_temp(self,
|
||||||
zone_id:int,
|
zone_id:int,
|
||||||
) -> float:
|
) -> float:
|
||||||
""" get target temp of specific area """
|
""" 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.area_target_temp(id_zone = zone_id)
|
ret, temp = await self._client.area_target_temp(id_zone = zone_id)
|
||||||
if not ret:
|
if not ret:
|
||||||
_LOGGER.error("Error reading target temp for area with ID: {}".format(zone_id))
|
_LOGGER.error("Error reading target temp for area with ID: {}".format(zone_id))
|
||||||
return 0.0
|
return 0.0
|
||||||
for idx, area in enumerate(self._areas):
|
self._areas[_idx].order_temp = temp
|
||||||
if area.id_zone == zone_id:
|
|
||||||
# update areas list value from modbus response
|
|
||||||
self._areas[idx].order_temp = temp
|
|
||||||
return temp
|
return temp
|
||||||
|
|
||||||
async def set_area_clim_mode(self,
|
async def set_area_clim_mode(self,
|
||||||
@@ -428,6 +453,7 @@ class Koolnova:
|
|||||||
""" set climate mode for specific area """
|
""" set climate mode for specific area """
|
||||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||||
if not _ret:
|
if not _ret:
|
||||||
|
_LOGGER.error("Area not defined ...")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if mode == const.ZoneClimMode.OFF:
|
if mode == const.ZoneClimMode.OFF:
|
||||||
@@ -462,6 +488,7 @@ class Koolnova:
|
|||||||
# test if area id is defined
|
# test if area id is defined
|
||||||
_ret, _idx = self._area_defined(id_search = zone_id)
|
_ret, _idx = self._area_defined(id_search = zone_id)
|
||||||
if not _ret:
|
if not _ret:
|
||||||
|
_LOGGER.error("Area not defined ...")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
|
if self._areas[_idx].state == const.ZoneState.STATE_OFF:
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ class Operations:
|
|||||||
regs, ret = await self.__read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)),
|
regs, ret = await self.__read_registers(start_reg = const.REG_START_ZONE + (4 * (zone_id - 1)),
|
||||||
count = const.NUM_REG_PER_ZONE)
|
count = const.NUM_REG_PER_ZONE)
|
||||||
if not ret:
|
if not ret:
|
||||||
raise ReadRegistersError("Read holding regsiter error")
|
raise ReadRegistersError("Error reading holding register")
|
||||||
if const.ZoneRegister(regs[0] >> 1) == const.ZoneRegister.REGISTER_OFF:
|
if const.ZoneRegister(regs[0] >> 1) == const.ZoneRegister.REGISTER_OFF:
|
||||||
_LOGGER.warning("Zone with id: {} is not registered".format(zone_id))
|
_LOGGER.warning("Zone with id: {} is not registered".format(zone_id))
|
||||||
return False, {}
|
return False, {}
|
||||||
@@ -194,6 +194,28 @@ class Operations:
|
|||||||
zone_dict['real_temp'] = regs[3]/2
|
zone_dict['real_temp'] = regs[3]/2
|
||||||
return True, zone_dict
|
return True, zone_dict
|
||||||
|
|
||||||
|
async def areas_registered(self) -> (bool, dict):
|
||||||
|
""" Get all areas values """
|
||||||
|
_areas_dict:dict = {}
|
||||||
|
regs, ret = await self.__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 = {}
|
||||||
|
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 system_status(self) -> (bool, const.SysState):
|
async def system_status(self) -> (bool, const.SysState):
|
||||||
''' Read system status register '''
|
''' Read system status register '''
|
||||||
reg, ret = await self.__read_register(const.REG_SYS_STATE)
|
reg, ret = await self.__read_register(const.REG_SYS_STATE)
|
||||||
|
|||||||
Reference in New Issue
Block a user