class Switch(Device): """Class for managing a switch.""" def __init__(self, xknx, name, group_address=None, group_address_state=None, device_updated_cb=None): """Initialize Switch class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) self.switch = RemoteValueSwitch(xknx, group_address, group_address_state, device_name=self.name, after_update_cb=self.after_update) def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield self.switch @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address = \ config.get('group_address') group_address_state = \ config.get('group_address_state') return cls(xknx, name, group_address=group_address, group_address_state=group_address_state) @property def state(self): """Return the current switch state of the device.""" # None will return False return bool(self.switch.value) async def set_on(self): """Switch on switch.""" await self.switch.on() async def set_off(self): """Switch off switch.""" await self.switch.off() async def do(self, action): """Execute 'do' commands.""" if action == "on": await self.set_on() elif action == "off": await self.set_off() else: self.xknx.logger.warning( "Could not understand action %s for device %s", action, self.get_name()) async def process_group_write(self, telegram): """Process incoming GROUP WRITE telegram.""" await self.switch.process(telegram) def __str__(self): """Return object as readable string.""" return '<Switch name="{0}" switch="{1}" />' \ .format(self.name, self.switch.group_addr_str()) def __eq__(self, other): """Equal operator.""" return self.__dict__ == other.__dict__
class Light(Device): """Class for managing a light.""" DEFAULT_MIN_KELVIN = 2700 # 370 mireds DEFAULT_MAX_KELVIN = 6000 # 166 mireds def __init__( self, xknx: XKNX, name: str, group_address_switch: GroupAddressesType | None = None, group_address_switch_state: GroupAddressesType | None = None, group_address_brightness: GroupAddressesType | None = None, group_address_brightness_state: GroupAddressesType | None = None, group_address_color: GroupAddressesType | None = None, group_address_color_state: GroupAddressesType | None = None, group_address_rgbw: GroupAddressesType | None = None, group_address_rgbw_state: GroupAddressesType | None = None, group_address_tunable_white: GroupAddressesType | None = None, group_address_tunable_white_state: GroupAddressesType | None = None, group_address_color_temperature: GroupAddressesType | None = None, group_address_color_temperature_state: GroupAddressesType | None = None, group_address_switch_red: GroupAddressesType | None = None, group_address_switch_red_state: GroupAddressesType | None = None, group_address_brightness_red: GroupAddressesType | None = None, group_address_brightness_red_state: GroupAddressesType | None = None, group_address_switch_green: GroupAddressesType | None = None, group_address_switch_green_state: GroupAddressesType | None = None, group_address_brightness_green: GroupAddressesType | None = None, group_address_brightness_green_state: GroupAddressesType | None = None, group_address_switch_blue: GroupAddressesType | None = None, group_address_switch_blue_state: GroupAddressesType | None = None, group_address_brightness_blue: GroupAddressesType | None = None, group_address_brightness_blue_state: GroupAddressesType | None = None, group_address_switch_white: GroupAddressesType | None = None, group_address_switch_white_state: GroupAddressesType | None = None, group_address_brightness_white: GroupAddressesType | None = None, group_address_brightness_white_state: GroupAddressesType | None = None, min_kelvin: int | None = None, max_kelvin: int | None = None, device_updated_cb: DeviceCallbackType | None = None, ): """Initialize Light class.""" super().__init__(xknx, name, device_updated_cb) self.switch = RemoteValueSwitch( xknx, group_address_switch, group_address_switch_state, device_name=self.name, feature_name="State", after_update_cb=self.after_update, ) self.brightness = RemoteValueScaling( xknx, group_address_brightness, group_address_brightness_state, device_name=self.name, feature_name="Brightness", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color = RemoteValueColorRGB( xknx, group_address_color, group_address_color_state, device_name=self.name, after_update_cb=self.after_update, ) self.rgbw = RemoteValueColorRGBW( xknx, group_address_rgbw, group_address_rgbw_state, device_name=self.name, after_update_cb=self.after_update, ) self.tunable_white = RemoteValueScaling( xknx, group_address_tunable_white, group_address_tunable_white_state, device_name=self.name, feature_name="Tunable white", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color_temperature = RemoteValueDpt2ByteUnsigned( xknx, group_address_color_temperature, group_address_color_temperature_state, device_name=self.name, feature_name="Color temperature", after_update_cb=self.after_update, ) self.red = _SwitchAndBrightness( xknx, self.name, "red", group_address_switch_red, group_address_switch_red_state, group_address_brightness_red, group_address_brightness_red_state, self.after_update, ) self.green = _SwitchAndBrightness( xknx, self.name, "green", group_address_switch_green, group_address_switch_green_state, group_address_brightness_green, group_address_brightness_green_state, self.after_update, ) self.blue = _SwitchAndBrightness( xknx, self.name, "blue", group_address_switch_blue, group_address_switch_blue_state, group_address_brightness_blue, group_address_brightness_blue_state, self.after_update, ) self.white = _SwitchAndBrightness( xknx, self.name, "white", group_address_switch_white, group_address_switch_white_state, group_address_brightness_white, group_address_brightness_white_state, self.after_update, ) self.min_kelvin = min_kelvin self.max_kelvin = max_kelvin def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]: """Iterate the devices RemoteValue classes.""" yield self.switch yield self.brightness yield self.color yield self.rgbw yield self.tunable_white yield self.color_temperature for color in self._iter_individual_colors(): yield color.switch yield color.brightness def _iter_individual_colors(self) -> Iterator[_SwitchAndBrightness]: """Iterate the devices individual colors.""" yield from (self.red, self.green, self.blue, self.white) @property def unique_id(self) -> str | None: """Return unique id for this device.""" if self.switch.group_address is not None: return f"{self.switch.group_address}" return ( f"{self.red.brightness.group_address}_{self.green.brightness.group_address}_" f"{self.blue.brightness.group_address}_{self.white.brightness.group_address}" ) @property def supports_brightness(self) -> bool: """Return if light supports brightness.""" return self.brightness.initialized @property def supports_color(self) -> bool: """Return if light supports color.""" return self.color.initialized or all( c.brightness.initialized for c in (self.red, self.green, self.blue)) @property def supports_rgbw(self) -> bool: """Return if light supports RGBW.""" return self.rgbw.initialized or all( c.brightness.initialized for c in self._iter_individual_colors()) @property def supports_tunable_white(self) -> bool: """Return if light supports tunable white / relative color temperature.""" return self.tunable_white.initialized @property def supports_color_temperature(self) -> bool: """Return if light supports absolute color temperature.""" return self.color_temperature.initialized def __str__(self) -> str: """Return object as readable string.""" str_brightness = ("" if not self.supports_brightness else f" brightness={self.brightness.group_addr_str()}") str_color = ("" if not self.supports_color else f" color={self.color.group_addr_str()}") str_rgbw = ("" if not self.supports_rgbw else f" rgbw={self.rgbw.group_addr_str()}") str_tunable_white = ( "" if not self.supports_tunable_white else f" tunable_white={self.tunable_white.group_addr_str()}") str_color_temperature = ( "" if not self.supports_color_temperature else f" color_temperature={self.color_temperature.group_addr_str()}") str_red_state = ("" if not self.red.switch.initialized else f" red_state={self.red.switch.group_addr_str()}") str_red_brightness = ( "" if not self.red.brightness.initialized else f" red_brightness={self.red.brightness.group_addr_str()}") str_green_state = ( "" if not self.green.switch.initialized else f" green_state={self.green.switch.group_addr_str()}") str_green_brightness = ( "" if not self.green.brightness.initialized else f" green_brightness={self.green.brightness.group_addr_str()}") str_blue_state = ("" if not self.blue.switch.initialized else f" blue_state={self.blue.switch.group_addr_str()}") str_blue_brightness = ( "" if not self.blue.brightness.initialized else f" blue_brightness={self.blue.brightness.group_addr_str()}") str_white_state = ( "" if not self.white.switch.initialized else f" white_state={self.white.switch.group_addr_str()}") str_white_brightness = ( "" if not self.white.brightness.initialized else f" white_brightness={self.white.brightness.group_addr_str()}") return '<Light name="{}" ' "switch={}{}{}{}{}{}{}{}{}{}{}{}{}{} />".format( self.name, self.switch.group_addr_str(), str_brightness, str_color, str_rgbw, str_tunable_white, str_color_temperature, str_red_state, str_red_brightness, str_green_state, str_green_brightness, str_blue_state, str_blue_brightness, str_white_state, str_white_brightness, ) @property def state(self) -> bool | None: """Return the current switch state of the device.""" if self.switch.value is not None: return self.switch.value if any(c.is_on is not None for c in self._iter_individual_colors()): return any(c.is_on for c in self._iter_individual_colors()) return None async def set_on(self) -> None: """Switch light on.""" if self.switch.initialized: await self.switch.on() for color in self._iter_individual_colors(): await color.set_on() async def set_off(self) -> None: """Switch light off.""" if self.switch.initialized: await self.switch.off() for color in self._iter_individual_colors(): await color.set_off() @property def current_brightness(self) -> int | None: """Return current brightness of light between 0..255.""" return self.brightness.value async def set_brightness(self, brightness: int) -> None: """Set brightness of light.""" if not self.supports_brightness: logger.warning("Dimming not supported for device %s", self.get_name()) return await self.brightness.set(brightness) @property def current_color(self) -> tuple[tuple[int, int, int] | None, int | None]: """ Return current color of light. If the device supports RGBW, get the current RGB+White values instead. """ if self.supports_rgbw: if self.rgbw.initialized: if not self.rgbw.value: return None, None return self.rgbw.value[:3], self.rgbw.value[3] if self.color.initialized: return self.color.value, None # individual RGB addresses - white will return None when it is not initialized colors = ( self.red.brightness.value, self.green.brightness.value, self.blue.brightness.value, ) if None in colors: return None, self.white.brightness.value return cast(Tuple[int, int, int], colors), self.white.brightness.value async def set_color(self, color: tuple[int, int, int], white: int | None = None) -> None: """ Set color of a light device. If also the white value is given and the device supports RGBW, set all four values. """ if white is not None: if self.supports_rgbw: if self.rgbw.initialized: await self.rgbw.set(list(color) + [white]) return if all(c.brightness.initialized for c in self._iter_individual_colors()): await self.red.brightness.set(color[0]) await self.green.brightness.set(color[1]) await self.blue.brightness.set(color[2]) await self.white.brightness.set(white) return logger.warning("RGBW not supported for device %s", self.get_name()) else: if self.supports_color: if self.color.initialized: await self.color.set(color) return if all(c.brightness.initialized for c in (self.red, self.green, self.blue)): await self.red.brightness.set(color[0]) await self.green.brightness.set(color[1]) await self.blue.brightness.set(color[2]) return logger.warning("Colors not supported for device %s", self.get_name()) @property def current_tunable_white(self) -> int | None: """Return current relative color temperature of light.""" return self.tunable_white.value async def set_tunable_white(self, tunable_white: int) -> None: """Set relative color temperature of light.""" if not self.supports_tunable_white: logger.warning("Tunable white not supported for device %s", self.get_name()) return await self.tunable_white.set(tunable_white) @property def current_color_temperature(self) -> int | None: """Return current absolute color temperature of light.""" return self.color_temperature.value async def set_color_temperature(self, color_temperature: int) -> None: """Set absolute color temperature of light.""" if not self.supports_color_temperature: logger.warning( "Absolute Color Temperature not supported for device %s", self.get_name(), ) return await self.color_temperature.set(color_temperature) async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram)
class BinarySensor(Device): """Class for binary sensor.""" # pylint: disable=too-many-instance-attributes def __init__( self, xknx, name, group_address_state=None, sync_state: bool = True, ignore_internal_state: bool = True, device_class: str = None, reset_after: Optional[float] = None, actions: List[Action] = None, context_timeout: Optional[float] = None, device_updated_cb=None, ): """Initialize BinarySensor class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) if actions is None: actions = [] self.actions = actions self.device_class = device_class self.ignore_internal_state = ignore_internal_state self.reset_after = reset_after self.state = None self._context_timeout = context_timeout self._count_set_on = 0 self._count_set_off = 0 self._last_set = None self._reset_task = None self._context_task = None # TODO: log a warning if reset_after and sync_state are true ? This could cause actions to self-fire. self.remote_value = RemoteValueSwitch( xknx, group_address_state=group_address_state, sync_state=sync_state, device_name=self.name, # after_update called internally after_update_cb=self._state_from_remote_value, ) def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield self.remote_value def __del__(self): """Destructor. Cleaning up if this was not done before.""" if self._reset_task: self._reset_task.cancel() if self._context_task: self._context_task.cancel() @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address_state = config.get("group_address_state") context_timeout = config.get("context_timeout") reset_after = config.get("reset_after") sync_state = config.get("sync_state", True) device_class = config.get("device_class") ignore_internal_state = config.get("ignore_internal_state", True) actions = [] if "actions" in config: for action in config["actions"]: action = Action.from_config(xknx, action) actions.append(action) return cls( xknx, name, group_address_state=group_address_state, sync_state=sync_state, ignore_internal_state=ignore_internal_state, context_timeout=context_timeout, reset_after=reset_after, device_class=device_class, actions=actions, ) async def _state_from_remote_value(self): """Update the internal state from RemoteValue (Callback).""" await self._set_internal_state(self.remote_value.value) async def _set_internal_state(self, state: bool): """Set the internal state of the device. If state was changed after_update hooks and connected Actions are executed.""" if state != self.state or self.ignore_internal_state: self.state = state self.bump_and_get_counter(state) if self.ignore_internal_state and self._context_timeout: if self._context_task: self._context_task.cancel() self._context_task = asyncio.create_task( self._counter_task(self._context_timeout)) else: await self._trigger_callbacks() async def _counter_task(self, wait_seconds: float): """Trigger after 1 second to prevent double triggers.""" await asyncio.sleep(wait_seconds) await self._trigger_callbacks() self._count_set_on = 0 self._count_set_off = 0 await self.after_update() async def _trigger_callbacks(self): """Trigger callbacks for device and execute actions if any.""" await self.after_update() for action in self.actions: if action.test_if_applicable(self.state, self.counter): await action.execute() @property def counter(self): """Return current counter for sensor.""" return self._count_set_on if self.state else self._count_set_off def bump_and_get_counter(self, state: bool) -> int: """Bump counter and return the number of times a state was set to the same value within CONTEXT_TIMEOUT.""" def within_same_context() -> bool: """Check if state change was within same context (e.g. 'Button was pressed twice').""" if self._last_set is None: self._last_set = time.time() return False new_set_time = time.time() time_diff = new_set_time - self._last_set self._last_set = new_set_time return time_diff < self._context_timeout if self._context_timeout and within_same_context(): if state: self._count_set_on = self._count_set_on + 1 return self._count_set_on self._count_set_off = self._count_set_off + 1 return self._count_set_off if state: self._count_set_on = 1 self._count_set_off = 0 else: self._count_set_on = 0 self._count_set_off = 1 return 1 async def process_group_write(self, telegram): """Process incoming and outgoing GROUP WRITE telegram.""" if await self.remote_value.process(telegram, always_callback=True): if self.reset_after is not None and self.state: if self._reset_task: self._reset_task.cancel() self._reset_task = asyncio.create_task( self._reset_state(self.reset_after)) async def _reset_state(self, wait_seconds: float): await asyncio.sleep(wait_seconds) await self._set_internal_state(False) def is_on(self) -> bool: """Return if binary sensor is 'on'.""" return bool(self.state) def is_off(self) -> bool: """Return if binary sensor is 'off'.""" return not self.state def __str__(self): """Return object as readable string.""" return '<BinarySensor name="{}" remote_value="{}" state="{}"/>'.format( self.name, self.remote_value.group_addr_str(), self.state)
class Cover(Device): """Class for managing a cover.""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods # pylint: disable=too-many-locals # Average typical travel time of a cover DEFAULT_TRAVEL_TIME_DOWN = 22 DEFAULT_TRAVEL_TIME_UP = 22 def __init__( self, xknx: "XKNX", name: str, group_address_long: Optional["GroupAddressableType"] = None, group_address_short: Optional["GroupAddressableType"] = None, group_address_stop: Optional["GroupAddressableType"] = None, group_address_position: Optional["GroupAddressableType"] = None, group_address_position_state: Optional["GroupAddressableType"] = None, group_address_angle: Optional["GroupAddressableType"] = None, group_address_angle_state: Optional["GroupAddressableType"] = None, travel_time_down: float = DEFAULT_TRAVEL_TIME_DOWN, travel_time_up: float = DEFAULT_TRAVEL_TIME_UP, invert_position: bool = False, invert_angle: bool = False, device_updated_cb: Optional[DeviceCallbackType] = None, device_class: Optional[str] = None, ): """Initialize Cover class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) # self.after_update for position changes is called after updating the # travelcalculator (in process_group_write and set_*) - angle changes # are updated from RemoteValue objects self.updown = RemoteValueUpDown( xknx, group_address_long, device_name=self.name, after_update_cb=None, invert=invert_position, ) self.step = RemoteValueStep( xknx, group_address_short, device_name=self.name, after_update_cb=self.after_update, invert=invert_position, ) self.stop_ = RemoteValueSwitch( xknx, group_address=group_address_stop, device_name=self.name, after_update_cb=None, ) position_range_from = 100 if invert_position else 0 position_range_to = 0 if invert_position else 100 self.position_current = RemoteValueScaling( xknx, group_address_state=group_address_position_state, device_name=self.name, feature_name="Position", after_update_cb=self._current_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) self.position_target = RemoteValueScaling( xknx, group_address=group_address_position, device_name=self.name, feature_name="Target position", after_update_cb=self._target_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) angle_range_from = 100 if invert_angle else 0 angle_range_to = 0 if invert_angle else 100 self.angle = RemoteValueScaling( xknx, group_address_angle, group_address_angle_state, device_name=self.name, feature_name="Tilt angle", after_update_cb=self.after_update, range_from=angle_range_from, range_to=angle_range_to, ) self.travel_time_down = travel_time_down self.travel_time_up = travel_time_up self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up) self.device_class = device_class def _iter_remote_values(self) -> Iterator["RemoteValue"]: """Iterate the devices RemoteValue classes.""" yield from ( self.updown, self.step, self.stop_, self.position_current, self.position_target, self.angle, ) @classmethod def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Cover": """Initialize object from configuration structure.""" group_address_long = config.get("group_address_long") group_address_short = config.get("group_address_short") group_address_stop = config.get("group_address_stop") group_address_position = config.get("group_address_position") group_address_position_state = config.get("group_address_position_state") group_address_angle = config.get("group_address_angle") group_address_angle_state = config.get("group_address_angle_state") travel_time_down = config.get("travel_time_down", cls.DEFAULT_TRAVEL_TIME_DOWN) travel_time_up = config.get("travel_time_up", cls.DEFAULT_TRAVEL_TIME_UP) invert_position = config.get("invert_position", False) invert_angle = config.get("invert_angle", False) device_class = config.get("device_class") return cls( xknx, name, group_address_long=group_address_long, group_address_short=group_address_short, group_address_stop=group_address_stop, group_address_position=group_address_position, group_address_position_state=group_address_position_state, group_address_angle=group_address_angle, group_address_angle_state=group_address_angle_state, travel_time_down=travel_time_down, travel_time_up=travel_time_up, invert_position=invert_position, invert_angle=invert_angle, device_class=device_class, ) def __str__(self) -> str: """Return object as readable string.""" return ( '<Cover name="{}" ' 'updown="{}" ' 'step="{}" ' 'stop="{}" ' 'position_current="{}" ' 'position_target="{}" ' 'angle="{}" ' 'travel_time_down="{}" ' 'travel_time_up="{}" />'.format( self.name, self.updown.group_addr_str(), self.step.group_addr_str(), self.stop_.group_addr_str(), self.position_current.group_addr_str(), self.position_target.group_addr_str(), self.angle.group_addr_str(), self.travel_time_down, self.travel_time_up, ) ) async def set_down(self) -> None: """Move cover down.""" await self.updown.down() self.travelcalculator.start_travel_down() await self.after_update() async def set_up(self) -> None: """Move cover up.""" await self.updown.up() self.travelcalculator.start_travel_up() await self.after_update() async def set_short_down(self) -> None: """Move cover short down.""" await self.step.increase() async def set_short_up(self) -> None: """Move cover short up.""" await self.step.decrease() async def stop(self) -> None: """Stop cover.""" if self.stop_.writable: await self.stop_.on() elif self.step.writable: await self.step.increase() else: logger.warning("Stop not supported for device %s", self.get_name()) return self.travelcalculator.stop() await self.after_update() async def set_position(self, position: int) -> None: """Move cover to a desginated postion.""" if not self.position_target.writable: # No direct positioning group address defined # fully open or close is always possible even if current position is not known current_position = self.current_position() if current_position is None: if position == self.travelcalculator.position_open: await self.updown.up() elif position == self.travelcalculator.position_closed: await self.updown.down() else: logger.warning( "Current position unknown. Initialize cover by moving to end position." ) return elif position < current_position: await self.updown.up() elif position > current_position: await self.updown.down() self.travelcalculator.start_travel(position) await self.after_update() else: await self.position_target.set(position) async def _target_position_from_rv(self) -> None: """Update the target postion from RemoteValue (Callback).""" self.travelcalculator.start_travel(self.position_target.value) await self.after_update() async def _current_position_from_rv(self) -> None: """Update the current postion from RemoteValue (Callback).""" position_before_update = self.travelcalculator.current_position() if self.is_traveling(): self.travelcalculator.update_position(self.position_current.value) else: self.travelcalculator.set_position(self.position_current.value) if position_before_update != self.travelcalculator.current_position(): await self.after_update() async def set_angle(self, angle: int) -> None: """Move cover to designated angle.""" if not self.supports_angle: logger.warning("Angle not supported for device %s", self.get_name()) return await self.angle.set(angle) async def auto_stop_if_necessary(self) -> None: """Do auto stop if necessary.""" # If device does not support auto_positioning, # we have to stop the device when position is reached. # unless device was traveling to fully open # or fully closed state if ( self.supports_stop and not self.position_target.writable and self.position_reached() and not self.is_open() and not self.is_closed() ): await self.stop() async def do(self, action: str) -> None: """Execute 'do' commands.""" if action == "up": await self.set_up() elif action == "short_up": await self.set_short_up() elif action == "down": await self.set_down() elif action == "short_down": await self.set_short_down() elif action == "stop": await self.stop() else: logger.warning( "Could not understand action %s for device %s", action, self.get_name() ) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await self.position_current.read_state(wait_for_result=wait_for_result) await self.angle.read_state(wait_for_result=wait_for_result) async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" # call after_update to account for travelcalculator changes if await self.updown.process(telegram): if ( not self.is_opening() and self.updown.value == RemoteValueUpDown.Direction.UP ): self.travelcalculator.start_travel_up() await self.after_update() elif ( not self.is_closing() and self.updown.value == RemoteValueUpDown.Direction.DOWN ): self.travelcalculator.start_travel_down() await self.after_update() # stop from bus if await self.stop_.process(telegram) or await self.step.process(telegram): if self.is_traveling(): self.travelcalculator.stop() await self.after_update() await self.position_current.process(telegram, always_callback=True) await self.position_target.process(telegram) await self.angle.process(telegram) def current_position(self) -> Optional[int]: """Return current position of cover.""" return self.travelcalculator.current_position() def current_angle(self) -> Optional[int]: """Return current tilt angle of cover.""" return self.angle.value # type: ignore def is_traveling(self) -> bool: """Return if cover is traveling at the moment.""" return self.travelcalculator.is_traveling() def position_reached(self) -> bool: """Return if cover has reached its final position.""" return self.travelcalculator.position_reached() def is_open(self) -> bool: """Return if cover is open.""" return self.travelcalculator.is_open() def is_closed(self) -> bool: """Return if cover is closed.""" return self.travelcalculator.is_closed() def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.travelcalculator.is_opening() def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.travelcalculator.is_closing() @property def supports_stop(self) -> bool: """Return if cover supports manual stopping.""" return self.stop_.writable or self.step.writable @property def supports_position(self) -> bool: """Return if cover supports direct positioning.""" return self.position_target.initialized @property def supports_angle(self) -> bool: """Return if cover supports tilt angle.""" return self.angle.initialized
class BinarySensor(Device): """Class for binary sensor.""" # pylint: disable=too-many-instance-attributes CONTEXT_TIMEOUT = 1 def __init__(self, xknx, name, group_address_state=None, sync_state=True, ignore_internal_state=False, device_class=None, reset_after=None, actions=None, device_updated_cb=None): """Initialize BinarySensor class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) if actions is None: actions = [] self.actions = actions self.device_class = device_class self.ignore_internal_state = ignore_internal_state self.reset_after = reset_after self.state = False self._count_set_on = 0 self._count_set_off = 0 self._last_set = None self._reset_task = None # TODO: log a warning if reset_after and sync_state are true ? This could cause actions to self-fire. self.remote_value = RemoteValueSwitch( xknx, group_address_state=group_address_state, sync_state=sync_state, device_name=self.name, # after_update called internally after_update_cb=self._state_from_remote_value) def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield self.remote_value def __del__(self): """Destructor. Cleaning up if this was not done before.""" if self._reset_task: self._reset_task.cancel() @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address_state = \ config.get('group_address_state') sync_state = \ config.get('sync_state', True) device_class = \ config.get('device_class') ignore_internal_state = \ config.get('ignore_internal_state', False) actions = [] if "actions" in config: for action in config["actions"]: action = Action.from_config(xknx, action) actions.append(action) return cls(xknx, name, group_address_state=group_address_state, sync_state=sync_state, ignore_internal_state=ignore_internal_state, device_class=device_class, actions=actions) async def _state_from_remote_value(self): """Update the internal state from ReomteValue (Callback).""" await self._set_internal_state(self.remote_value.value) async def _set_internal_state(self, state): """Set the internal state of the device. If state was changed after_update hooks and connected Actions are executed.""" if state != self.state or self.ignore_internal_state: self.state = state counter = self.bump_and_get_counter(state) await self.after_update() for action in self.actions: if action.test_if_applicable(self.state, counter): await action.execute() def bump_and_get_counter(self, state): """Bump counter and return the number of times a state was set to the same value within CONTEXT_TIMEOUT.""" def within_same_context(): """Check if state change was within same context (e.g. 'Button was pressed twice').""" if self._last_set is None: self._last_set = time.time() return False new_set_time = time.time() time_diff = new_set_time - self._last_set self._last_set = new_set_time return time_diff < self.CONTEXT_TIMEOUT if within_same_context(): if state: self._count_set_on = self._count_set_on + 1 return self._count_set_on self._count_set_off = self._count_set_off + 1 return self._count_set_off if state: self._count_set_on = 1 self._count_set_off = 0 else: self._count_set_on = 0 self._count_set_off = 1 return 1 async def process_group_write(self, telegram): """Process incoming GROUP WRITE telegram.""" if await self.remote_value.process(telegram, always_callback=True): if self.reset_after is not None and \ self.state: if self._reset_task: self._reset_task.cancel() self._reset_task = self.xknx.loop.create_task( self._reset_state(self.reset_after / 1000)) async def _reset_state(self, wait_seconds): await asyncio.sleep(wait_seconds) await self._set_internal_state(False) def is_on(self): """Return if binary sensor is 'on'.""" return self.state def is_off(self): """Return if binary sensor is 'off'.""" return not self.state def __str__(self): """Return object as readable string.""" return '<BinarySensor name="{0}" remote_value="{1}" state="{2}"/>' \ .format(self.name, self.remote_value.group_addr_str(), self.state) def __eq__(self, other): """Equal operator.""" return self.__dict__ == other.__dict__
class Weather(Device): """Class for managing a weather device.""" def __init__( self, xknx: XKNX, name: str, group_address_temperature: GroupAddressesType | None = None, group_address_brightness_south: GroupAddressesType | None = None, group_address_brightness_north: GroupAddressesType | None = None, group_address_brightness_west: GroupAddressesType | None = None, group_address_brightness_east: GroupAddressesType | None = None, group_address_wind_speed: GroupAddressesType | None = None, group_address_wind_bearing: GroupAddressesType | None = None, group_address_rain_alarm: GroupAddressesType | None = None, group_address_frost_alarm: GroupAddressesType | None = None, group_address_wind_alarm: GroupAddressesType | None = None, group_address_day_night: GroupAddressesType | None = None, group_address_air_pressure: GroupAddressesType | None = None, group_address_humidity: GroupAddressesType | None = None, create_sensors: bool = False, sync_state: bool = True, device_updated_cb: DeviceCallbackType | None = None, ) -> None: """Initialize Weather class.""" super().__init__(xknx, name, device_updated_cb) self._temperature = RemoteValueSensor( xknx, group_address_state=group_address_temperature, sync_state=sync_state, value_type="temperature", device_name=self.name, feature_name="Temperature", after_update_cb=self.after_update, ) self._brightness_south = RemoteValueSensor( xknx, group_address_state=group_address_brightness_south, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness south", after_update_cb=self.after_update, ) self._brightness_north = RemoteValueSensor( xknx, group_address_state=group_address_brightness_north, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness north", after_update_cb=self.after_update, ) self._brightness_west = RemoteValueSensor( xknx, group_address_state=group_address_brightness_west, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness west", after_update_cb=self.after_update, ) self._brightness_east = RemoteValueSensor( xknx, group_address_state=group_address_brightness_east, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness east", after_update_cb=self.after_update, ) self._wind_speed = RemoteValueSensor( xknx, group_address_state=group_address_wind_speed, sync_state=sync_state, value_type="wind_speed_ms", device_name=self.name, feature_name="Wind speed", after_update_cb=self.after_update, ) self._wind_bearing = RemoteValueSensor( xknx, group_address_state=group_address_wind_bearing, sync_state=sync_state, value_type="angle", device_name=self.name, feature_name="Wind bearing", after_update_cb=self.after_update, ) self._rain_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_rain_alarm, device_name=self.name, feature_name="Rain alarm", after_update_cb=self.after_update, ) self._frost_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_frost_alarm, device_name=self.name, feature_name="Frost alarm", after_update_cb=self.after_update, ) self._wind_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_wind_alarm, device_name=self.name, feature_name="Wind alarm", after_update_cb=self.after_update, ) self._day_night = RemoteValueSwitch( xknx, group_address_state=group_address_day_night, device_name=self.name, feature_name="Day/Night", after_update_cb=self.after_update, ) self._air_pressure = RemoteValueSensor( xknx, group_address_state=group_address_air_pressure, sync_state=sync_state, value_type="pressure_2byte", device_name=self.name, feature_name="Air pressure", after_update_cb=self.after_update, ) self._humidity = RemoteValueSensor( xknx, group_address_state=group_address_humidity, sync_state=sync_state, value_type="humidity", device_name=self.name, feature_name="Humidity", after_update_cb=self.after_update, ) if create_sensors: self.create_sensors() def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]: """Iterate the devices remote values.""" yield self._temperature yield self._brightness_south yield self._brightness_north yield self._brightness_east yield self._brightness_west yield self._wind_speed yield self._wind_bearing yield self._rain_alarm yield self._wind_alarm yield self._frost_alarm yield self._day_night yield self._air_pressure yield self._humidity @property def unique_id(self) -> str | None: """Return unique id for this device.""" return f"{self._temperature.group_address_state}" async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram) @property def temperature(self) -> float | None: """Return current temperature.""" return self._temperature.value # type: ignore @property def brightness_south(self) -> float: """Return brightness south.""" if self._brightness_south.value is not None: return self._brightness_south.value # type: ignore return 0.0 @property def brightness_north(self) -> float: """Return brightness north.""" if self._brightness_north.value is not None: return self._brightness_north.value # type: ignore return 0.0 @property def brightness_east(self) -> float: """Return brightness east.""" if self._brightness_east.value is not None: return self._brightness_east.value # type: ignore return 0.0 @property def brightness_west(self) -> float: """Return brightness west.""" if self._brightness_west.value is not None: return self._brightness_west.value # type: ignore return 0.0 @property def wind_speed(self) -> float | None: """Return wind speed in m/s.""" return self._wind_speed.value # type: ignore @property def wind_bearing(self) -> int | None: """Return wind bearing in °.""" return self._wind_bearing.value # type: ignore @property def rain_alarm(self) -> bool | None: """Return True if rain alarm False if not.""" return self._rain_alarm.value @property def wind_alarm(self) -> bool | None: """Return True if wind alarm False if not.""" return self._wind_alarm.value @property def frost_alarm(self) -> bool | None: """Return True if frost alarm False if not.""" return self._frost_alarm.value @property def day_night(self) -> bool | None: """Return day or night.""" return self._day_night.value @property def air_pressure(self) -> float | None: """Return pressure in Pa.""" return self._air_pressure.value # type: ignore @property def humidity(self) -> float | None: """Return humidity in %.""" return self._humidity.value # type: ignore @property def max_brightness(self) -> float: """Return highest illuminance from all sensors.""" return max( self.brightness_west, self.brightness_south, self.brightness_north, self.brightness_east, ) def create_sensors(self) -> None: """Expose sensors to xknx.""" for suffix, group_address in ( ("_rain_alarm", self._rain_alarm.group_address_state), ("_wind_alarm", self._wind_alarm.group_address_state), ("_frost_alarm", self._frost_alarm.group_address_state), ("_day_night", self._day_night.group_address_state), ): if group_address is not None: BinarySensor( self.xknx, name=self.name + suffix, group_address_state=group_address, ) for suffix, group_address, value_type in ( ( "_temperature", self._temperature.group_address_state, "temperature", ), ( "_brightness_south", self._brightness_south.group_address_state, "illuminance", ), ( "_brightness_north", self._brightness_north.group_address_state, "illuminance", ), ( "_brightness_west", self._brightness_west.group_address_state, "illuminance", ), ( "_brightness_east", self._brightness_east.group_address_state, "illuminance", ), ( "_wind_speed", self._wind_speed.group_address_state, "wind_speed_ms", ), ( "_wind_bearing", self._wind_bearing.group_address_state, "angle", ), ( "_air_pressure", self._air_pressure.group_address_state, "pressure", ), ( "_humidity", self._humidity.group_address_state, "humidity", ), ): if group_address is not None: Sensor( self.xknx, name=self.name + suffix, group_address_state=group_address, value_type=value_type, ) def ha_current_state(self, current_date: date = date.today()) -> WeatherCondition: """Return the current state for home assistant.""" def _get_season(now: date) -> Season: """Return winter or summer.""" if isinstance(now, datetime): now = now.date() now = now.replace(year=YEAR) return next( season for season, (start, end) in SEASONS if start <= now <= end ) if self.wind_alarm and self.rain_alarm: return WeatherCondition.LIGHTNING_RAINY if self.frost_alarm and self.rain_alarm: return WeatherCondition.SNOWY_RAINY if self.rain_alarm: return WeatherCondition.RAINY if self.wind_alarm: return WeatherCondition.WINDY current_season: Season = _get_season(current_date) _season: Season function: Callable[[float], bool] result: WeatherCondition for _season, function, result in ILLUMINANCE_MAPPING: if _season == current_season and function(self.max_brightness): return result if self.day_night is False: return WeatherCondition.CLEAR_NIGHT return WeatherCondition.EXCEPTIONAL def __str__(self) -> str: """Return object as readable string.""" return ( '<Weather name="{}" ' "temperature={} brightness_south={} brightness_north={} brightness_west={} " "brightness_east={} wind_speed={} wind_bearing={} rain_alarm={} " "wind_alarm={} frost_alarm={} day_night={} " "air_pressure={} humidity={} />".format( self.name, self._temperature.group_addr_str(), self._brightness_south.group_addr_str(), self._brightness_north.group_addr_str(), self._brightness_west.group_addr_str(), self._brightness_east.group_addr_str(), self._wind_speed.group_addr_str(), self._wind_bearing.group_addr_str(), self._rain_alarm.group_addr_str(), self._wind_alarm.group_addr_str(), self._frost_alarm.group_addr_str(), self._day_night.group_addr_str(), self._air_pressure.group_addr_str(), self._humidity.group_addr_str(), ) )
class Cover(Device): """Class for managing a cover.""" # pylint: disable=too-many-instance-attributes # pylint: disable=too-many-public-methods # pylint: disable=too-many-locals # Average typical travel time of a cover DEFAULT_TRAVEL_TIME_DOWN = 22 DEFAULT_TRAVEL_TIME_UP = 22 def __init__(self, xknx, name, group_address_long=None, group_address_short=None, group_address_stop=None, group_address_position=None, group_address_position_state=None, group_address_angle=None, group_address_angle_state=None, travel_time_down=DEFAULT_TRAVEL_TIME_DOWN, travel_time_up=DEFAULT_TRAVEL_TIME_UP, invert_position=False, invert_angle=False, device_updated_cb=None): """Initialize Cover class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) # self.after_update for position changes is called after updating the # travelcalculator (in process_group_write and set_*) - angle changes # are updated from RemoteValue objects self.updown = RemoteValueUpDown(xknx, group_address_long, device_name=self.name, after_update_cb=None) self.step = RemoteValueStep(xknx, group_address_short, device_name=self.name, after_update_cb=self.after_update) self.stop_ = RemoteValueSwitch(xknx, group_address=group_address_stop, device_name=self.name, after_update_cb=None) position_range_from = 100 if invert_position else 0 position_range_to = 0 if invert_position else 100 self.position = RemoteValueScaling(xknx, group_address_position, group_address_position_state, device_name=self.name, feature_name="Position", after_update_cb=None, range_from=position_range_from, range_to=position_range_to) angle_range_from = 100 if invert_angle else 0 angle_range_to = 0 if invert_angle else 100 self.angle = RemoteValueScaling(xknx, group_address_angle, group_address_angle_state, device_name=self.name, feature_name="Tilt angle", after_update_cb=self.after_update, range_from=angle_range_from, range_to=angle_range_to) self.travel_time_down = travel_time_down self.travel_time_up = travel_time_up self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up) def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield from (self.updown, self.step, self.stop_, self.position, self.angle) @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address_long = \ config.get('group_address_long') group_address_short = \ config.get('group_address_short') group_address_stop = \ config.get('group_address_stop') group_address_position = \ config.get('group_address_position') group_address_position_state = \ config.get('group_address_position_state') group_address_angle = \ config.get('group_address_angle') group_address_angle_state = \ config.get('group_address_angle_state') travel_time_down = \ config.get('travel_time_down', cls.DEFAULT_TRAVEL_TIME_DOWN) travel_time_up = \ config.get('travel_time_up', cls.DEFAULT_TRAVEL_TIME_UP) invert_position = \ config.get('invert_position', False) invert_angle = \ config.get('invert_angle', False) return cls(xknx, name, group_address_long=group_address_long, group_address_short=group_address_short, group_address_stop=group_address_stop, group_address_position=group_address_position, group_address_position_state=group_address_position_state, group_address_angle=group_address_angle, group_address_angle_state=group_address_angle_state, travel_time_down=travel_time_down, travel_time_up=travel_time_up, invert_position=invert_position, invert_angle=invert_angle) def __str__(self): """Return object as readable string.""" return '<Cover name="{0}" ' \ 'updown="{1}" ' \ 'step="{2}" ' \ 'stop="{3}" ' \ 'position="{4}" ' \ 'angle="{5}" '\ 'travel_time_down="{6}" ' \ 'travel_time_up="{7}" />' \ .format( self.name, self.updown.group_addr_str(), self.step.group_addr_str(), self.stop_.group_addr_str(), self.position.group_addr_str(), self.angle.group_addr_str(), self.travel_time_down, self.travel_time_up) async def set_down(self): """Move cover down.""" await self.updown.down() self.travelcalculator.start_travel_down() await self.after_update() async def set_up(self): """Move cover up.""" await self.updown.up() self.travelcalculator.start_travel_up() await self.after_update() async def set_short_down(self): """Move cover short down.""" await self.step.increase() async def set_short_up(self): """Move cover short up.""" await self.step.decrease() async def stop(self): """Stop cover.""" if self.stop_.writable: await self.stop_.on() elif self.step.writable: await self.step.increase() else: self.xknx.logger.warning('Stop not supported for device %s', self.get_name()) return self.travelcalculator.stop() await self.after_update() async def set_position(self, position): """Move cover to a desginated postion.""" if not self.position.writable: # No direct positioning group address defined # fully open or close is always possible even if current position is not known current_position = self.current_position() if current_position is None: if position == self.travelcalculator.position_open: await self.updown.up() elif position == self.travelcalculator.position_closed: await self.updown.down() else: self.xknx.logger.warning( "Current position unknown. Initialize cover by moving to end position." ) return elif position < current_position: await self.updown.up() elif position > current_position: await self.updown.down() else: await self.position.set(position) self.travelcalculator.start_travel(position) await self.after_update() async def set_angle(self, angle): """Move cover to designated angle.""" if not self.supports_angle: self.xknx.logger.warning('Angle not supported for device %s', self.get_name()) return await self.angle.set(angle) async def auto_stop_if_necessary(self): """Do auto stop if necessary.""" # If device does not support auto_positioning, # we have to stop the device when position is reached. # unless device was traveling to fully open # or fully closed state if (self.supports_stop and not self.position.writable and self.position_reached() and not self.is_open() and not self.is_closed()): await self.stop() async def do(self, action): """Execute 'do' commands.""" if action == "up": await self.set_up() elif action == "short_up": await self.set_short_up() elif action == "down": await self.set_down() elif action == "short_down": await self.set_short_down() elif action == "stop": await self.stop() else: self.xknx.logger.warning( "Could not understand action %s for device %s", action, self.get_name()) async def sync(self): """Read states of device from KNX bus.""" await self.position.read_state() await self.angle.read_state() async def process_group_write(self, telegram): """Process incoming GROUP WRITE telegram.""" if await self.updown.process(telegram): if self.updown.value == RemoteValueUpDown.Direction.UP: self.travelcalculator.start_travel_up() else: self.travelcalculator.start_travel_down() # call after_update to account for travelcalculator changes await self.after_update() # stop from bus if await self.stop_.process(telegram) or await self.step.process( telegram): if self.is_traveling(): self.travelcalculator.stop() await self.after_update() if await self.position.process(telegram): # distinction between new target position and position update from bus if telegram.group_address == self.position.group_address_state: if self.is_traveling(): self.travelcalculator.update_position(self.position.value) else: self.travelcalculator.set_position(self.position.value) else: self.travelcalculator.start_travel(self.position.value) await self.after_update() await self.angle.process(telegram) def current_position(self): """Return current position of cover.""" return self.travelcalculator.current_position() def current_angle(self): """Return current tilt angle of cover.""" return self.angle.value def is_traveling(self): """Return if cover is traveling at the moment.""" return self.travelcalculator.is_traveling() def position_reached(self): """Return if cover has reached its final position.""" return self.travelcalculator.position_reached() def is_open(self): """Return if cover is open.""" return self.travelcalculator.is_open() def is_closed(self): """Return if cover is closed.""" return self.travelcalculator.is_closed() def is_opening(self): """Return if the cover is opening or not.""" return self.travelcalculator.is_opening() def is_closing(self): """Return if the cover is closing or not.""" return self.travelcalculator.is_closing() @property def supports_stop(self): """Return if cover supports manual stopping.""" return self.stop_.writable or self.step.writable @property def supports_position(self): """Return if cover supports direct positioning.""" return self.position.initialized @property def supports_angle(self): """Return if cover supports tilt angle.""" return self.angle.initialized def __eq__(self, other): """Equal operator.""" return self.__dict__ == other.__dict__
class Switch(Device): """Class for managing a switch.""" def __init__( self, xknx: XKNX, name: str, group_address: GroupAddressesType | None = None, group_address_state: GroupAddressesType | None = None, invert: bool = False, reset_after: float | None = None, device_updated_cb: DeviceCallbackType | None = None, ): """Initialize Switch class.""" super().__init__(xknx, name, device_updated_cb) self.reset_after = reset_after self._reset_task: asyncio.Task[None] | None = None self.switch = RemoteValueSwitch( xknx, group_address, group_address_state, invert=invert, device_name=self.name, after_update_cb=self.after_update, ) def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]: """Iterate the devices RemoteValue classes.""" yield self.switch def __del__(self) -> None: """Destructor. Cleaning up if this was not done before.""" if self._reset_task: try: self._reset_task.cancel() except RuntimeError: pass super().__del__() @property def unique_id(self) -> str | None: """Return unique id for this device.""" return f"{self.switch.group_address}" @property def state(self) -> bool | None: """Return the current switch state of the device.""" return self.switch.value async def set_on(self) -> None: """Switch on switch.""" await self.switch.on() async def set_off(self) -> None: """Switch off switch.""" await self.switch.off() async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" if await self.switch.process(telegram): if self.reset_after is not None and self.switch.value: if self._reset_task: self._reset_task.cancel() self._reset_task = asyncio.create_task( self._reset_state(self.reset_after)) async def _reset_state(self, wait_seconds: float) -> None: await asyncio.sleep(wait_seconds) await self.set_off() def __str__(self) -> str: """Return object as readable string.""" return '<Switch name="{}" switch={} />'.format( self.name, self.switch.group_addr_str())
class Light(Device): """Class for managing a light.""" # pylint: disable=too-many-locals DEFAULT_MIN_KELVIN = 2700 # 370 mireds DEFAULT_MAX_KELVIN = 6000 # 166 mireds def __init__(self, xknx, name, group_address_switch=None, group_address_switch_state=None, group_address_brightness=None, group_address_brightness_state=None, group_address_color=None, group_address_color_state=None, group_address_rgbw=None, group_address_rgbw_state=None, group_address_tunable_white=None, group_address_tunable_white_state=None, group_address_color_temperature=None, group_address_color_temperature_state=None, min_kelvin=None, max_kelvin=None, device_updated_cb=None): """Initialize Light class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) self.switch = RemoteValueSwitch(xknx, group_address_switch, group_address_switch_state, device_name=self.name, feature_name="State", after_update_cb=self.after_update) self.brightness = RemoteValueScaling(xknx, group_address_brightness, group_address_brightness_state, device_name=self.name, feature_name="Brightness", after_update_cb=self.after_update, range_from=0, range_to=255) self.color = RemoteValueColorRGB(xknx, group_address_color, group_address_color_state, device_name=self.name, after_update_cb=self.after_update) self.rgbw = RemoteValueColorRGBW(xknx, group_address_rgbw, group_address_rgbw_state, device_name=self.name, after_update_cb=self.after_update) self.tunable_white = RemoteValueScaling( xknx, group_address_tunable_white, group_address_tunable_white_state, device_name=self.name, feature_name="Tunable white", after_update_cb=self.after_update, range_from=0, range_to=255) self.color_temperature = RemoteValueDpt2ByteUnsigned( xknx, group_address_color_temperature, group_address_color_temperature_state, device_name=self.name, feature_name="Color temperature", after_update_cb=self.after_update) self.min_kelvin = min_kelvin self.max_kelvin = max_kelvin def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield from (self.switch, self.brightness, self.color, self.rgbw, self.tunable_white, self.color_temperature) @property def supports_brightness(self): """Return if light supports brightness.""" return self.brightness.initialized @property def supports_color(self): """Return if light supports color.""" return self.color.initialized @property def supports_rgbw(self): """Return if light supports RGBW.""" return self.rgbw.initialized @property def supports_tunable_white(self): """Return if light supports tunable white / relative color temperature.""" return self.tunable_white.initialized @property def supports_color_temperature(self): """Return if light supports absolute color temperature.""" return self.color_temperature.initialized @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address_switch = \ config.get('group_address_switch') group_address_switch_state = \ config.get('group_address_switch_state') group_address_brightness = \ config.get('group_address_brightness') group_address_brightness_state = \ config.get('group_address_brightness_state') group_address_color = \ config.get('group_address_color') group_address_color_state = \ config.get('group_address_color_state') group_address_rgbw = \ config.get('group_address_rgbw') group_address_rgbw_state = \ config.get('group_address_rgbw_state') group_address_tunable_white = \ config.get('group_address_tunable_white') group_address_tunable_white_state = \ config.get('group_address_tunable_white_state') group_address_color_temperature = \ config.get('group_address_color_temperature') group_address_color_temperature_state = \ config.get('group_address_color_temperature_state') min_kelvin = \ config.get('min_kelvin', Light.DEFAULT_MIN_KELVIN) max_kelvin = \ config.get('max_kelvin', Light.DEFAULT_MAX_KELVIN) return cls( xknx, name, group_address_switch=group_address_switch, group_address_switch_state=group_address_switch_state, group_address_brightness=group_address_brightness, group_address_brightness_state=group_address_brightness_state, group_address_color=group_address_color, group_address_color_state=group_address_color_state, group_address_rgbw=group_address_rgbw, group_address_rgbw_state=group_address_rgbw_state, group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temperature, group_address_color_temperature_state= group_address_color_temperature_state, min_kelvin=min_kelvin, max_kelvin=max_kelvin) def __str__(self): """Return object as readable string.""" str_brightness = '' if not self.supports_brightness else \ ' brightness="{0}"'.format( self.brightness.group_addr_str()) str_color = '' if not self.supports_color else \ ' color="{0}"'.format( self.color.group_addr_str()) str_rgbw = '' if not self.supports_rgbw else \ ' rgbw="{0}"'.format( self.rgbw.group_addr_str()) str_tunable_white = '' if not self.supports_tunable_white else \ ' tunable white="{0}"'.format( self.tunable_white.group_addr_str()) str_color_temperature = '' if not self.supports_color_temperature else \ ' color temperature="{0}"'.format( self.color_temperature.group_addr_str()) return '<Light name="{0}" ' \ 'switch="{1}"{2}{3}{4}{5}{6} />' \ .format( self.name, self.switch.group_addr_str(), str_brightness, str_color, str_rgbw, str_tunable_white, str_color_temperature) @property def state(self): """Return the current switch state of the device.""" # None will return False return bool(self.switch.value) async def set_on(self): """Switch light on.""" await self.switch.on() async def set_off(self): """Switch light off.""" await self.switch.off() @property def current_brightness(self): """Return current brightness of light.""" return self.brightness.value async def set_brightness(self, brightness): """Set brightness of light.""" if not self.supports_brightness: self.xknx.logger.warning("Dimming not supported for device %s", self.get_name()) return await self.brightness.set(brightness) @property def current_color(self): """ Return current color of light. If the device supports RGBW, get the current RGB+White values instead. """ if self.supports_rgbw: if not self.rgbw.value: return None, None return self.rgbw.value[:3], self.rgbw.value[3] return self.color.value, None async def set_color(self, color, white=None): """ Set color of a light device. If also the white value is given and the device supports RGBW, set all four values. """ if white is not None: if self.supports_rgbw: await self.rgbw.set(list(color) + [white]) return self.xknx.logger.warning("RGBW not supported for device %s", self.get_name()) else: if self.supports_color: await self.color.set(color) return self.xknx.logger.warning("Colors not supported for device %s", self.get_name()) @property def current_tunable_white(self): """Return current relative color temperature of light.""" return self.tunable_white.value async def set_tunable_white(self, tunable_white): """Set relative color temperature of light.""" if not self.supports_tunable_white: self.xknx.logger.warning( "Tunable white not supported for device %s", self.get_name()) return await self.tunable_white.set(tunable_white) @property def current_color_temperature(self): """Return current absolute color temperature of light.""" return self.color_temperature.value async def set_color_temperature(self, color_temperature): """Set absolute color temperature of light.""" if not self.supports_color_temperature: self.xknx.logger.warning( "Absolute Color Temperature not supported for device %s", self.get_name()) return await self.color_temperature.set(color_temperature) async def do(self, action): """Execute 'do' commands.""" if action == "on": await self.set_on() elif action == "off": await self.set_off() elif action.startswith("brightness:"): await self.set_brightness(int(action[11:])) elif action.startswith("tunable_white:"): await self.set_tunable_white(int(action[14:])) elif action.startswith("color_temperature:"): await self.set_color_temperature(int(action[18:])) else: self.xknx.logger.warning( "Could not understand action %s for device %s", action, self.get_name()) async def process_group_write(self, telegram): """Process incoming GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram) def __eq__(self, other): """Equal operator.""" return self.__dict__ == other.__dict__
class Switch(Device): """Class for managing a switch.""" def __init__( self, xknx, name: str, group_address=None, group_address_state=None, invert: Optional[bool] = False, reset_after: Optional[float] = None, device_updated_cb=None, ): """Initialize Switch class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) self.reset_after = reset_after self._reset_task = None self.switch = RemoteValueSwitch( xknx, group_address, group_address_state, invert=invert, device_name=self.name, after_update_cb=self.after_update, ) def _iter_remote_values(self): """Iterate the devices RemoteValue classes.""" yield self.switch def __del__(self): """Destructor. Cleaning up if this was not done before.""" if self._reset_task: self._reset_task.cancel() @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" group_address = config.get("group_address") group_address_state = config.get("group_address_state") invert = config.get("invert") reset_after = config.get("reset_after") return cls( xknx, name, group_address=group_address, group_address_state=group_address_state, invert=invert, reset_after=reset_after, ) @property def state(self) -> Optional[bool]: """Return the current switch state of the device.""" return self.switch.value async def set_on(self): """Switch on switch.""" await self.switch.on() async def set_off(self): """Switch off switch.""" await self.switch.off() async def do(self, action): """Execute 'do' commands.""" if action == "on": await self.set_on() elif action == "off": await self.set_off() else: logger.warning("Could not understand action %s for device %s", action, self.get_name()) async def process_group_write(self, telegram): """Process incoming and outgoing GROUP WRITE telegram.""" if await self.switch.process(telegram): if self.reset_after is not None and self.switch.value: if self._reset_task: self._reset_task.cancel() self._reset_task = asyncio.create_task( self._reset_state(self.reset_after)) async def _reset_state(self, wait_seconds: float): await asyncio.sleep(wait_seconds) await self.set_off() def __str__(self): """Return object as readable string.""" return '<Switch name="{}" switch="{}" />'.format( self.name, self.switch.group_addr_str())
class BinarySensor(Device): """Class for binary sensor.""" def __init__( self, xknx: XKNX, name: str, group_address_state: GroupAddressesType = None, invert: bool = False, sync_state: bool = True, ignore_internal_state: bool = False, device_class: str | None = None, reset_after: float | None = None, context_timeout: float | None = None, ha_value_template: Any = None, device_updated_cb: DeviceCallbackType | None = None, ): """Initialize BinarySensor class.""" super().__init__(xknx, name, device_updated_cb) self.device_class = device_class self.ha_value_template = ha_value_template self.ignore_internal_state = ignore_internal_state or bool(context_timeout) self.reset_after = reset_after self.state: bool | None = None self._context_timeout = context_timeout self._count_set_on = 0 self._count_set_off = 0 self._last_set: float | None = None self._reset_task: asyncio.Task[None] | None = None self._context_task: asyncio.Task[None] | None = None # TODO: log a warning if reset_after and sync_state are true ? This could cause actions to self-fire. self.remote_value = RemoteValueSwitch( xknx, group_address_state=group_address_state, invert=invert, sync_state=sync_state, device_name=self.name, # after_update called internally after_update_cb=self._state_from_remote_value, ) def _iter_remote_values(self) -> Iterator[RemoteValueSwitch]: """Iterate the devices RemoteValue classes.""" yield self.remote_value @property def unique_id(self) -> str | None: """Return unique id for this device.""" return f"{self.remote_value.group_address_state}" @property def last_telegram(self) -> Telegram | None: """Return the last telegram received from the RemoteValue.""" return self.remote_value.telegram def __del__(self) -> None: """Destructor. Cleaning up if this was not done before.""" try: if self._reset_task: self._reset_task.cancel() if self._context_task: self._context_task.cancel() except RuntimeError: pass super().__del__() async def _state_from_remote_value(self) -> None: """Update the internal state from RemoteValue (Callback).""" if self.remote_value.value is not None: await self._set_internal_state(self.remote_value.value) async def _set_internal_state(self, state: bool) -> None: """Set the internal state of the device. If state was changed after_update hooks and connected Actions are executed.""" if state != self.state or self.ignore_internal_state: self.state = state if self.ignore_internal_state and self._context_timeout: self.bump_and_get_counter(state) if self._context_task: self._context_task.cancel() self._context_task = asyncio.create_task( self._counter_task(self._context_timeout) ) else: await self._trigger_callbacks() async def _counter_task(self, wait_seconds: float) -> None: """Trigger after 1 second to prevent double triggers.""" await asyncio.sleep(wait_seconds) await self._trigger_callbacks() self._count_set_on = 0 self._count_set_off = 0 await self.after_update() async def _trigger_callbacks(self) -> None: """Trigger callbacks for device if any.""" await self.after_update() @property def counter(self) -> int | None: """Return current counter for sensor.""" if self._context_timeout: return self._count_set_on if self.state else self._count_set_off return None def bump_and_get_counter(self, state: bool) -> int: """Bump counter and return the number of times a state was set to the same value within CONTEXT_TIMEOUT.""" def within_same_context() -> bool: """Check if state change was within same context (e.g. 'Button was pressed twice').""" if self._last_set is None: self._last_set = time.time() return False new_set_time = time.time() time_diff = new_set_time - self._last_set self._last_set = new_set_time return time_diff < cast(float, self._context_timeout) if within_same_context(): if state: self._count_set_on = self._count_set_on + 1 return self._count_set_on self._count_set_off = self._count_set_off + 1 return self._count_set_off if state: self._count_set_on = 1 self._count_set_off = 0 else: self._count_set_on = 0 self._count_set_off = 1 return 1 async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" if await self.remote_value.process(telegram, always_callback=True): self._process_reset_after() async def process_group_response(self, telegram: "Telegram") -> None: """Process incoming GroupValueResponse telegrams.""" if await self.remote_value.process(telegram, always_callback=False): self._process_reset_after() def _process_reset_after(self) -> None: """Create Task for resetting state if 'reset_after' is configured.""" if self.reset_after is not None and self.state: if self._reset_task: self._reset_task.cancel() self._reset_task = asyncio.create_task(self._reset_state(self.reset_after)) async def _reset_state(self, wait_seconds: float) -> None: await asyncio.sleep(wait_seconds) await self._set_internal_state(False) def is_on(self) -> bool: """Return if binary sensor is 'on'.""" return bool(self.state) def is_off(self) -> bool: """Return if binary sensor is 'off'.""" return not self.state def __str__(self) -> str: """Return object as readable string.""" return '<BinarySensor name="{}" remote_value={} state={} />'.format( self.name, self.remote_value.group_addr_str(), self.state.__repr__() )
class Climate(Device): """Class for managing the climate.""" # pylint: disable=too-many-instance-attributes,invalid-name def __init__( self, xknx: "XKNX", name: str, group_address_temperature: Optional["GroupAddressableType"] = None, group_address_target_temperature: Optional["GroupAddressableType"] = None, group_address_target_temperature_state: Optional["GroupAddressableType"] = None, group_address_setpoint_shift: Optional["GroupAddressableType"] = None, group_address_setpoint_shift_state: Optional["GroupAddressableType"] = None, setpoint_shift_mode: SetpointShiftMode = DEFAULT_SETPOINT_SHIFT_MODE, setpoint_shift_max: float = DEFAULT_SETPOINT_SHIFT_MAX, setpoint_shift_min: float = DEFAULT_SETPOINT_SHIFT_MIN, temperature_step: float = DEFAULT_TEMPERATURE_STEP, group_address_on_off: Optional["GroupAddressableType"] = None, group_address_on_off_state: Optional["GroupAddressableType"] = None, on_off_invert: bool = False, min_temp: Optional[float] = None, max_temp: Optional[float] = None, mode: Optional[ClimateMode] = None, create_temperature_sensors: bool = False, device_updated_cb: Optional[DeviceCallbackType] = None, ): """Initialize Climate class.""" # pylint: disable=too-many-arguments, too-many-locals, too-many-branches, too-many-statements super().__init__(xknx, name, device_updated_cb) self.min_temp = min_temp self.max_temp = max_temp self.setpoint_shift_min = setpoint_shift_min self.setpoint_shift_max = setpoint_shift_max self.temperature_step = temperature_step self.temperature = RemoteValueTemp( xknx, group_address_state=group_address_temperature, device_name=self.name, feature_name="Current temperature", after_update_cb=self.after_update, ) self.target_temperature = RemoteValueTemp( xknx, group_address_target_temperature, group_address_target_temperature_state, device_name=self.name, feature_name="Target temperature", after_update_cb=self.after_update, ) self._setpoint_shift: Union[RemoteValueTemp, RemoteValueSetpointShift] if setpoint_shift_mode == SetpointShiftMode.DPT9002: self._setpoint_shift = RemoteValueTemp( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, device_name=self.name, after_update_cb=self.after_update, ) else: self._setpoint_shift = RemoteValueSetpointShift( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, device_name=self.name, after_update_cb=self.after_update, setpoint_shift_step=self.temperature_step, ) self.supports_on_off = ( group_address_on_off is not None or group_address_on_off_state is not None ) self.on = RemoteValueSwitch( xknx, group_address_on_off, group_address_on_off_state, device_name=self.name, after_update_cb=self.after_update, invert=on_off_invert, ) self.mode = mode if create_temperature_sensors: self.create_temperature_sensors() def _iter_remote_values(self) -> Iterator["RemoteValue[Any]"]: """Iterate the devices RemoteValue classes.""" yield from ( self.temperature, self.target_temperature, self._setpoint_shift, self.on, ) def create_temperature_sensors(self) -> None: """Create temperature sensors.""" for suffix, group_address, value_type in ( ( "temperature", self.temperature.group_address_state, "temperature", ), ( "target temperature", self.target_temperature.group_address_state, "temperature", ), ): if group_address is not None: Sensor( self.xknx, name=self.name + " " + suffix, group_address_state=group_address, value_type=value_type, ) @classmethod def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Climate": """Initialize object from configuration structure.""" # pylint: disable=too-many-locals group_address_temperature = config.get("group_address_temperature") group_address_target_temperature = config.get( "group_address_target_temperature" ) group_address_target_temperature_state = config.get( "group_address_target_temperature_state" ) group_address_setpoint_shift = config.get("group_address_setpoint_shift") group_address_setpoint_shift_state = config.get( "group_address_setpoint_shift_state" ) setpoint_shift_mode = config.get( "setpoint_shift_mode", DEFAULT_SETPOINT_SHIFT_MODE ) setpoint_shift_max = config.get( "setpoint_shift_max", DEFAULT_SETPOINT_SHIFT_MAX ) setpoint_shift_min = config.get( "setpoint_shift_min", DEFAULT_SETPOINT_SHIFT_MIN ) temperature_step = config.get("temperature_step", DEFAULT_TEMPERATURE_STEP) group_address_on_off = config.get("group_address_on_off") group_address_on_off_state = config.get("group_address_on_off_state") on_off_invert = config.get("on_off_invert", False) min_temp = config.get("min_temp") max_temp = config.get("max_temp") climate_mode = None if "mode" in config: climate_mode = ClimateMode.from_config( xknx=xknx, name=f"{name}_mode", config=config["mode"] ) return cls( xknx, name, group_address_temperature=group_address_temperature, group_address_target_temperature=group_address_target_temperature, group_address_target_temperature_state=group_address_target_temperature_state, group_address_setpoint_shift=group_address_setpoint_shift, group_address_setpoint_shift_state=group_address_setpoint_shift_state, setpoint_shift_mode=setpoint_shift_mode, setpoint_shift_max=setpoint_shift_max, setpoint_shift_min=setpoint_shift_min, temperature_step=temperature_step, group_address_on_off=group_address_on_off, group_address_on_off_state=group_address_on_off_state, on_off_invert=on_off_invert, min_temp=min_temp, max_temp=max_temp, mode=climate_mode, ) def has_group_address(self, group_address: "GroupAddress") -> bool: """Test if device has given group address.""" if self.mode is not None and self.mode.has_group_address(group_address): return True return super().has_group_address(group_address) @property def is_on(self) -> bool: """Return power status.""" # None will return False return bool(self.on.value) async def turn_on(self) -> None: """Set power status to on.""" await self.on.on() async def turn_off(self) -> None: """Set power status to off.""" await self.on.off() @property def initialized_for_setpoint_shift_calculations(self) -> bool: """Test if object is initialized for setpoint shift calculations.""" if not self._setpoint_shift.initialized: return False if self._setpoint_shift.value is None: return False if not self.target_temperature.initialized: return False if self.target_temperature.value is None: return False return True async def set_target_temperature(self, target_temperature: float) -> None: """Send new target temperature or setpoint_shift to KNX bus.""" if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations temperature_delta = target_temperature - self.base_temperature await self.set_setpoint_shift(temperature_delta) else: validated_temp = self.validate_value( target_temperature, self.min_temp, self.max_temp ) await self.target_temperature.set(validated_temp) @property def base_temperature(self) -> Optional[float]: """ Return the base temperature when setpoint_shift is initialized. Base temperature is the default temperature (setpoint-shift=0) for the active climate mode. As this value is usually not available via KNX, we have to derive this from the current target temperature and the current set point shift. """ if self.initialized_for_setpoint_shift_calculations: return cast(float, self.target_temperature.value - self.setpoint_shift) return None @property def setpoint_shift(self) -> Optional[float]: """Return current offset from base temperature in Kelvin.""" return self._setpoint_shift.value # type: ignore def validate_value( self, value: float, min_value: Optional[float], max_value: Optional[float] ) -> float: """Check boundaries of temperature and return valid temperature value.""" if (min_value is not None) and (value < min_value): logger.warning("Min value exceeded at %s: %s", self.name, value) return min_value if (max_value is not None) and (value > max_value): logger.warning("Max value exceeded at %s: %s", self.name, value) return max_value return value async def set_setpoint_shift(self, offset: float) -> None: """Send new temperature offset to KNX bus.""" validated_offset = self.validate_value( offset, self.setpoint_shift_min, self.setpoint_shift_max ) base_temperature = self.base_temperature await self._setpoint_shift.set(validated_offset) # broadcast new target temperature and set internally if self.target_temperature.writable and base_temperature is not None: await self.target_temperature.set(base_temperature + validated_offset) @property def target_temperature_max(self) -> Optional[float]: """Return the highest possible target temperature.""" if self.max_temp is not None: return self.max_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_max return None @property def target_temperature_min(self) -> Optional[float]: """Return the lowest possible target temperature.""" if self.min_temp is not None: return self.min_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_min return None async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram) if self.mode is not None: await self.mode.process_group_write(telegram) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await super().sync(wait_for_result=wait_for_result) if self.mode is not None: await self.mode.sync(wait_for_result=wait_for_result) def __str__(self) -> str: """Return object as readable string.""" return ( '<Climate name="{}" ' 'temperature="{}" ' 'target_temperature="{}" ' 'temperature_step="{}" ' 'setpoint_shift="{}" ' 'setpoint_shift_max="{}" ' 'setpoint_shift_min="{}" ' 'group_address_on_off="{}" ' "/>".format( self.name, self.temperature.group_addr_str(), self.target_temperature.group_addr_str(), self.temperature_step, self._setpoint_shift.group_addr_str(), self.setpoint_shift_max, self.setpoint_shift_min, self.on.group_addr_str(), ) )
class Climate(Device): """Class for managing the climate.""" def __init__( self, xknx: XKNX, name: str, group_address_temperature: GroupAddressesType | None = None, group_address_target_temperature: GroupAddressesType | None = None, group_address_target_temperature_state: GroupAddressesType | None = None, group_address_setpoint_shift: GroupAddressesType | None = None, group_address_setpoint_shift_state: GroupAddressesType | None = None, setpoint_shift_mode: SetpointShiftMode | None = None, setpoint_shift_max: float = DEFAULT_SETPOINT_SHIFT_MAX, setpoint_shift_min: float = DEFAULT_SETPOINT_SHIFT_MIN, temperature_step: float = DEFAULT_TEMPERATURE_STEP, group_address_on_off: GroupAddressesType | None = None, group_address_on_off_state: GroupAddressesType | None = None, on_off_invert: bool = False, min_temp: float | None = None, max_temp: float | None = None, mode: ClimateMode | None = None, create_temperature_sensors: bool = False, device_updated_cb: DeviceCallbackType | None = None, ): """Initialize Climate class.""" super().__init__(xknx, name, device_updated_cb) self.min_temp = min_temp self.max_temp = max_temp self.setpoint_shift_min = setpoint_shift_min self.setpoint_shift_max = setpoint_shift_max self.temperature_step = temperature_step self.temperature = RemoteValueTemp( xknx, group_address_state=group_address_temperature, device_name=self.name, feature_name="Current temperature", after_update_cb=self.after_update, ) self.target_temperature = RemoteValueTemp( xknx, group_address_target_temperature, group_address_target_temperature_state, device_name=self.name, feature_name="Target temperature", after_update_cb=self.after_update, ) self._setpoint_shift = RemoteValueSetpointShift( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, device_name=self.name, after_update_cb=self.after_update, setpoint_shift_mode=setpoint_shift_mode, setpoint_shift_step=self.temperature_step, ) self.supports_on_off = (group_address_on_off is not None or group_address_on_off_state is not None) self.on = RemoteValueSwitch( # pylint: disable=invalid-name xknx, group_address_on_off, group_address_on_off_state, device_name=self.name, after_update_cb=self.after_update, invert=on_off_invert, ) self.mode = mode if create_temperature_sensors: self.create_temperature_sensors() def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]: """Iterate the devices RemoteValue classes.""" yield self.temperature yield self.target_temperature yield self._setpoint_shift yield self.on def create_temperature_sensors(self) -> None: """Create temperature sensors.""" for suffix, group_address, value_type in ( ( "temperature", self.temperature.group_address_state, "temperature", ), ( "target temperature", self.target_temperature.group_address_state, "temperature", ), ): if group_address is not None: Sensor( self.xknx, name=self.name + " " + suffix, group_address_state=group_address, value_type=value_type, ) @property def unique_id(self) -> str | None: """Return unique id for this device.""" return f"{self.temperature.group_address_state}" def has_group_address(self, group_address: DeviceGroupAddress) -> bool: """Test if device has given group address.""" if self.mode is not None and self.mode.has_group_address( group_address): return True return super().has_group_address(group_address) @property def is_on(self) -> bool: """Return power status.""" # None will return False return bool(self.on.value) async def turn_on(self) -> None: """Set power status to on.""" await self.on.on() async def turn_off(self) -> None: """Set power status to off.""" await self.on.off() @property def initialized_for_setpoint_shift_calculations(self) -> bool: """Test if object is initialized for setpoint shift calculations.""" if (self._setpoint_shift.initialized and self._setpoint_shift.value is not None and self.target_temperature.initialized and self.target_temperature.value is not None): return True return False async def set_target_temperature(self, target_temperature: float) -> None: """Send new target temperature or setpoint_shift to KNX bus.""" if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations temperature_delta = target_temperature - self.base_temperature await self.set_setpoint_shift(temperature_delta) else: validated_temp = self.validate_value(target_temperature, self.min_temp, self.max_temp) await self.target_temperature.set(validated_temp) @property def base_temperature(self) -> float | None: """ Return the base temperature when setpoint_shift is initialized. Base temperature is the default temperature (setpoint-shift=0) for the active climate mode. As this value is usually not available via KNX, we have to derive this from the current target temperature and the current set point shift. """ # implies self.initialized_for_setpoint_shift_calculations in a mypy compatible way: if (self.target_temperature.value is not None and self._setpoint_shift.value is not None): return self.target_temperature.value - self._setpoint_shift.value return None @property def setpoint_shift(self) -> float | None: """Return current offset from base temperature in Kelvin.""" return self._setpoint_shift.value def validate_value(self, value: float, min_value: float | None, max_value: float | None) -> float: """Check boundaries of temperature and return valid temperature value.""" if (min_value is not None) and (value < min_value): logger.warning("Min value exceeded at %s: %s", self.name, value) return min_value if (max_value is not None) and (value > max_value): logger.warning("Max value exceeded at %s: %s", self.name, value) return max_value return value async def set_setpoint_shift(self, offset: float) -> None: """Send new temperature offset to KNX bus.""" validated_offset = self.validate_value(offset, self.setpoint_shift_min, self.setpoint_shift_max) base_temperature = self.base_temperature await self._setpoint_shift.set(validated_offset) # broadcast new target temperature and set internally if self.target_temperature.writable and base_temperature is not None: await self.target_temperature.set(base_temperature + validated_offset) @property def target_temperature_max(self) -> float | None: """Return the highest possible target temperature.""" if self.max_temp is not None: return self.max_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_max return None @property def target_temperature_min(self) -> float | None: """Return the lowest possible target temperature.""" if self.min_temp is not None: return self.min_temp if self.base_temperature is not None: # implies initialized_for_setpoint_shift_calculations return self.base_temperature + self.setpoint_shift_min return None async def process_group_write(self, telegram: Telegram) -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram) if self.mode is not None: await self.mode.process_group_write(telegram) async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await super().sync(wait_for_result=wait_for_result) if self.mode is not None: await self.mode.sync(wait_for_result=wait_for_result) def __str__(self) -> str: """Return object as readable string.""" return ('<Climate name="{}" ' "temperature={} " "target_temperature={} " 'temperature_step="{}" ' "setpoint_shift={} " 'setpoint_shift_max="{}" ' 'setpoint_shift_min="{}" ' "group_address_on_off={} " "/>".format( self.name, self.temperature.group_addr_str(), self.target_temperature.group_addr_str(), self.temperature_step, self._setpoint_shift.group_addr_str(), self.setpoint_shift_max, self.setpoint_shift_min, self.on.group_addr_str(), ))
class Weather(Device): """Class for managing a weather device.""" # pylint: disable=too-many-locals def __init__( self, xknx: "XKNX", name: str, group_address_temperature: Optional["GroupAddressableType"] = None, group_address_brightness_south: Optional[ "GroupAddressableType"] = None, group_address_brightness_north: Optional[ "GroupAddressableType"] = None, group_address_brightness_west: Optional["GroupAddressableType"] = None, group_address_brightness_east: Optional["GroupAddressableType"] = None, group_address_wind_speed: Optional["GroupAddressableType"] = None, group_address_wind_bearing: Optional["GroupAddressableType"] = None, group_address_rain_alarm: Optional["GroupAddressableType"] = None, group_address_frost_alarm: Optional["GroupAddressableType"] = None, group_address_wind_alarm: Optional["GroupAddressableType"] = None, group_address_day_night: Optional["GroupAddressableType"] = None, group_address_air_pressure: Optional["GroupAddressableType"] = None, group_address_humidity: Optional["GroupAddressableType"] = None, create_sensors: bool = False, sync_state: bool = True, device_updated_cb: Optional[DeviceCallbackType] = None, ) -> None: """Initialize Weather class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) self._temperature = RemoteValueSensor( xknx, group_address_state=group_address_temperature, sync_state=sync_state, value_type="temperature", device_name=self.name, feature_name="Temperature", after_update_cb=self.after_update, ) self._brightness_south = RemoteValueSensor( xknx, group_address_state=group_address_brightness_south, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness south", after_update_cb=self.after_update, ) self._brightness_north = RemoteValueSensor( xknx, group_address_state=group_address_brightness_north, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness north", after_update_cb=self.after_update, ) self._brightness_west = RemoteValueSensor( xknx, group_address_state=group_address_brightness_west, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness west", after_update_cb=self.after_update, ) self._brightness_east = RemoteValueSensor( xknx, group_address_state=group_address_brightness_east, sync_state=sync_state, value_type="illuminance", device_name=self.name, feature_name="Brightness east", after_update_cb=self.after_update, ) self._wind_speed = RemoteValueSensor( xknx, group_address_state=group_address_wind_speed, sync_state=sync_state, value_type="wind_speed_ms", device_name=self.name, feature_name="Wind speed", after_update_cb=self.after_update, ) self._wind_bearing = RemoteValueSensor( xknx, group_address_state=group_address_wind_bearing, sync_state=sync_state, value_type="angle", device_name=self.name, feature_name="Wind bearing", after_update_cb=self.after_update, ) self._rain_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_rain_alarm, device_name=self.name, feature_name="Rain alarm", after_update_cb=self.after_update, ) self._frost_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_frost_alarm, device_name=self.name, feature_name="Frost alarm", after_update_cb=self.after_update, ) self._wind_alarm = RemoteValueSwitch( xknx, group_address_state=group_address_wind_alarm, device_name=self.name, feature_name="Wind alarm", after_update_cb=self.after_update, ) self._day_night = RemoteValueSwitch( xknx, group_address_state=group_address_day_night, device_name=self.name, feature_name="Day/Night", after_update_cb=self.after_update, ) self._air_pressure = RemoteValueSensor( xknx, group_address_state=group_address_air_pressure, sync_state=sync_state, value_type="pressure_2byte", device_name=self.name, feature_name="Air pressure", after_update_cb=self.after_update, ) self._humidity = RemoteValueSensor( xknx, group_address_state=group_address_humidity, sync_state=sync_state, value_type="humidity", device_name=self.name, feature_name="Humidity", after_update_cb=self.after_update, ) if create_sensors: self.create_sensors() def _iter_remote_values(self) -> Iterator[RemoteValue[Any]]: """Iterate the devices remote values.""" yield self._temperature yield self._brightness_south yield self._brightness_north yield self._brightness_east yield self._brightness_west yield self._wind_speed yield self._wind_bearing yield self._rain_alarm yield self._wind_alarm yield self._frost_alarm yield self._day_night yield self._air_pressure yield self._humidity async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram) @property def temperature(self) -> Optional[float]: """Return current temperature.""" return self._temperature.value # type: ignore @property def brightness_south(self) -> float: """Return brightness south.""" return ( # type: ignore 0.0 if self._brightness_south.value is None else self._brightness_south.value) @property def brightness_north(self) -> float: """Return brightness north.""" return ( # type: ignore 0.0 if self._brightness_north.value is None else self._brightness_north.value) @property def brightness_east(self) -> float: """Return brightness east.""" return ( # type: ignore 0.0 if self._brightness_east.value is None else self._brightness_east.value) @property def brightness_west(self) -> float: """Return brightness west.""" return ( # type: ignore 0.0 if self._brightness_west.value is None else self._brightness_west.value) @property def wind_speed(self) -> Optional[float]: """Return wind speed in m/s.""" return self._wind_speed.value # type: ignore @property def wind_bearing(self) -> Optional[int]: """Return wind bearing in °.""" return self._wind_bearing.value # type: ignore @property def rain_alarm(self) -> Optional[bool]: """Return True if rain alarm False if not.""" return self._rain_alarm.value # type: ignore @property def wind_alarm(self) -> Optional[bool]: """Return True if wind alarm False if not.""" return self._wind_alarm.value # type: ignore @property def frost_alarm(self) -> Optional[bool]: """Return True if frost alarm False if not.""" return self._frost_alarm.value # type: ignore @property def day_night(self) -> Optional[bool]: """Return day or night.""" return self._day_night.value # type: ignore @property def air_pressure(self) -> Optional[float]: """Return pressure in Pa.""" return self._air_pressure.value # type: ignore @property def humidity(self) -> Optional[float]: """Return humidity in %.""" return self._humidity.value # type: ignore @property def max_brightness(self) -> float: """Return highest illuminance from all sensors.""" return max( self.brightness_west, self.brightness_south, self.brightness_north, self.brightness_east, ) def create_sensors(self) -> None: """Expose sensors to xknx.""" for suffix, group_address in ( ("_rain_alarm", self._rain_alarm.group_address_state), ("_wind_alarm", self._wind_alarm.group_address_state), ("_frost_alarm", self._frost_alarm.group_address_state), ("_day_night", self._day_night.group_address_state), ): if group_address is not None: BinarySensor( self.xknx, name=self.name + suffix, group_address_state=group_address, ) for suffix, group_address, value_type in ( ( "_temperature", self._temperature.group_address_state, "temperature", ), ( "_brightness_south", self._brightness_south.group_address_state, "illuminance", ), ( "_brightness_north", self._brightness_north.group_address_state, "illuminance", ), ( "_brightness_west", self._brightness_west.group_address_state, "illuminance", ), ( "_brightness_east", self._brightness_east.group_address_state, "illuminance", ), ( "_wind_speed", self._wind_speed.group_address_state, "wind_speed_ms", ), ( "_wind_bearing", self._wind_bearing.group_address_state, "angle", ), ( "_air_pressure", self._air_pressure.group_address_state, "pressure", ), ( "_humidity", self._humidity.group_address_state, "humidity", ), ): if group_address is not None: Sensor( self.xknx, name=self.name + suffix, group_address_state=group_address, value_type=value_type, ) # pylint: disable=too-many-return-statements def ha_current_state( self, current_date: date = date.today()) -> WeatherCondition: """Return the current state for home assistant.""" def _get_season(now: date) -> Season: """Return winter or summer.""" if isinstance(now, datetime): now = now.date() now = now.replace(year=YEAR) return next(season for season, (start, end) in SEASONS if start <= now <= end) if self.wind_alarm and self.rain_alarm: return WeatherCondition.lightning_rainy if self.frost_alarm and self.rain_alarm: return WeatherCondition.snowy_rainy if self.rain_alarm: return WeatherCondition.rainy if self.wind_alarm: return WeatherCondition.windy current_season: Season = _get_season(current_date) _season: Season function: Callable[[float], bool] result: WeatherCondition for _season, function, result in ILLUMINANCE_MAPPING: if _season == current_season and function(self.max_brightness): return result if self.day_night is False: return WeatherCondition.clear_night return WeatherCondition.exceptional @classmethod def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Weather": """Initialize object from configuration structure.""" group_address_temperature = config.get("group_address_temperature") group_address_brightness_south = config.get( "group_address_brightness_south") group_address_brightness_north = config.get( "group_address_brightness_north") group_address_brightness_west = config.get( "group_address_brightness_west") group_address_brightness_east = config.get( "group_address_brightness_east") group_address_wind_speed = config.get("group_address_wind_speed") group_address_wind_bearing = config.get("group_address_wind_bearing") group_address_rain_alarm = config.get("group_address_rain_alarm") group_address_frost_alarm = config.get("group_address_frost_alarm") group_address_wind_alarm = config.get("group_address_wind_alarm") group_address_day_night = config.get("group_address_day_night") group_address_air_pressure = config.get("group_address_air_pressure") group_address_humidity = config.get("group_address_humidity") create_sensors = config.get("create_sensors", False) sync_state = config.get("sync_state", True) return cls( xknx, name, group_address_temperature=group_address_temperature, group_address_brightness_south=group_address_brightness_south, group_address_brightness_north=group_address_brightness_north, group_address_brightness_west=group_address_brightness_west, group_address_brightness_east=group_address_brightness_east, group_address_wind_speed=group_address_wind_speed, group_address_wind_bearing=group_address_wind_bearing, group_address_rain_alarm=group_address_rain_alarm, group_address_frost_alarm=group_address_frost_alarm, group_address_wind_alarm=group_address_wind_alarm, group_address_day_night=group_address_day_night, group_address_air_pressure=group_address_air_pressure, group_address_humidity=group_address_humidity, create_sensors=create_sensors, sync_state=sync_state, ) def __str__(self) -> str: """Return object as readable string.""" return ( '<Weather name="{}" ' 'temperature="{}" brightness_south="{}" brightness_north="{}" brightness_west="{}" ' 'brightness_east="{}" wind_speed="{}" wind_bearing="{}" rain_alarm="{}" ' 'wind_alarm="{}" frost_alarm="{}" day_night="{}" ' 'air_pressure="{}" humidity="{}" />'.format( self.name, self._temperature.group_addr_str(), self._brightness_south.group_addr_str(), self._brightness_north.group_addr_str(), self._brightness_west.group_addr_str(), self._brightness_east.group_addr_str(), self._wind_speed.group_addr_str(), self._wind_bearing.group_addr_str(), self._rain_alarm.group_addr_str(), self._wind_alarm.group_addr_str(), self._frost_alarm.group_addr_str(), self._day_night.group_addr_str(), self._air_pressure.group_addr_str(), self._humidity.group_addr_str(), ))
class Light(Device): """Class for managing a light.""" # pylint: disable=too-many-locals DEFAULT_MIN_KELVIN = 2700 # 370 mireds DEFAULT_MAX_KELVIN = 6000 # 166 mireds def __init__( self, xknx: "XKNX", name: str, group_address_switch: Optional["GroupAddressableType"] = None, group_address_switch_state: Optional["GroupAddressableType"] = None, group_address_brightness: Optional["GroupAddressableType"] = None, group_address_brightness_state: Optional["GroupAddressableType"] = None, group_address_color: Optional["GroupAddressableType"] = None, group_address_color_state: Optional["GroupAddressableType"] = None, group_address_rgbw: Optional["GroupAddressableType"] = None, group_address_rgbw_state: Optional["GroupAddressableType"] = None, group_address_tunable_white: Optional["GroupAddressableType"] = None, group_address_tunable_white_state: Optional["GroupAddressableType"] = None, group_address_color_temperature: Optional["GroupAddressableType"] = None, group_address_color_temperature_state: Optional["GroupAddressableType"] = None, group_address_switch_red: Optional["GroupAddressableType"] = None, group_address_switch_red_state: Optional["GroupAddressableType"] = None, group_address_brightness_red: Optional["GroupAddressableType"] = None, group_address_brightness_red_state: Optional["GroupAddressableType"] = None, group_address_switch_green: Optional["GroupAddressableType"] = None, group_address_switch_green_state: Optional["GroupAddressableType"] = None, group_address_brightness_green: Optional["GroupAddressableType"] = None, group_address_brightness_green_state: Optional["GroupAddressableType"] = None, group_address_switch_blue: Optional["GroupAddressableType"] = None, group_address_switch_blue_state: Optional["GroupAddressableType"] = None, group_address_brightness_blue: Optional["GroupAddressableType"] = None, group_address_brightness_blue_state: Optional["GroupAddressableType"] = None, group_address_switch_white: Optional["GroupAddressableType"] = None, group_address_switch_white_state: Optional["GroupAddressableType"] = None, group_address_brightness_white: Optional["GroupAddressableType"] = None, group_address_brightness_white_state: Optional["GroupAddressableType"] = None, min_kelvin: Optional[int] = None, max_kelvin: Optional[int] = None, device_updated_cb: Optional[DeviceCallbackType] = None, ): """Initialize Light class.""" # pylint: disable=too-many-arguments super().__init__(xknx, name, device_updated_cb) self.switch = RemoteValueSwitch( xknx, group_address_switch, group_address_switch_state, device_name=self.name, feature_name="State", after_update_cb=self.after_update, ) self.brightness = RemoteValueScaling( xknx, group_address_brightness, group_address_brightness_state, device_name=self.name, feature_name="Brightness", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color = RemoteValueColorRGB( xknx, group_address_color, group_address_color_state, device_name=self.name, after_update_cb=self.after_update, ) self.rgbw = RemoteValueColorRGBW( xknx, group_address_rgbw, group_address_rgbw_state, device_name=self.name, after_update_cb=self.after_update, ) self.tunable_white = RemoteValueScaling( xknx, group_address_tunable_white, group_address_tunable_white_state, device_name=self.name, feature_name="Tunable white", after_update_cb=self.after_update, range_from=0, range_to=255, ) self.color_temperature = RemoteValueDpt2ByteUnsigned( xknx, group_address_color_temperature, group_address_color_temperature_state, device_name=self.name, feature_name="Color temperature", after_update_cb=self.after_update, ) self.red = _SwitchAndBrightness( xknx, self.name, "red", group_address_switch_red, group_address_switch_red_state, group_address_brightness_red, group_address_brightness_red_state, self.after_update, ) self.green = _SwitchAndBrightness( xknx, self.name, "green", group_address_switch_green, group_address_switch_green_state, group_address_brightness_green, group_address_brightness_green_state, self.after_update, ) self.blue = _SwitchAndBrightness( xknx, self.name, "blue", group_address_switch_blue, group_address_switch_blue_state, group_address_brightness_blue, group_address_brightness_blue_state, self.after_update, ) self.white = _SwitchAndBrightness( xknx, self.name, "white", group_address_switch_white, group_address_switch_white_state, group_address_brightness_white, group_address_brightness_white_state, self.after_update, ) self.min_kelvin = min_kelvin self.max_kelvin = max_kelvin def _iter_remote_values(self) -> Iterator["RemoteValue"]: """Iterate the devices RemoteValue classes.""" yield from ( self.switch, self.brightness, self.color, self.rgbw, self.tunable_white, self.color_temperature, ) for color in (self.red, self.green, self.blue, self.white): yield color.switch yield color.brightness @property def supports_brightness(self) -> bool: """Return if light supports brightness.""" return self.brightness.initialized @property def supports_color(self) -> bool: """Return if light supports color.""" return self.color.initialized or all( [c.brightness.initialized for c in (self.red, self.green, self.blue)] ) @property def supports_rgbw(self) -> bool: """Return if light supports RGBW.""" return self.rgbw.initialized or all( [ c.brightness.initialized for c in (self.red, self.green, self.blue, self.white) ] ) @property def supports_tunable_white(self) -> bool: """Return if light supports tunable white / relative color temperature.""" return self.tunable_white.initialized @property def supports_color_temperature(self) -> bool: """Return if light supports absolute color temperature.""" return self.color_temperature.initialized @classmethod def read_color_from_config( cls, color: str, config: Any ) -> Tuple[ Optional["GroupAddressableType"], Optional["GroupAddressableType"], Optional["GroupAddressableType"], Optional["GroupAddressableType"], ]: """Load color configuration from configuration structure.""" if "individual_colors" in config and color in config["individual_colors"]: sub_config = config["individual_colors"][color] return ( sub_config.get("group_address_switch"), sub_config.get("group_address_switch_state"), sub_config.get("group_address_brightness"), sub_config.get("group_address_brightness_state"), ) return None, None, None, None @classmethod def from_config(cls, xknx: "XKNX", name: str, config: Any) -> "Light": """Initialize object from configuration structure.""" group_address_switch = config.get("group_address_switch") group_address_switch_state = config.get("group_address_switch_state") group_address_brightness = config.get("group_address_brightness") group_address_brightness_state = config.get("group_address_brightness_state") group_address_color = config.get("group_address_color") group_address_color_state = config.get("group_address_color_state") group_address_rgbw = config.get("group_address_rgbw") group_address_rgbw_state = config.get("group_address_rgbw_state") group_address_tunable_white = config.get("group_address_tunable_white") group_address_tunable_white_state = config.get( "group_address_tunable_white_state" ) group_address_color_temperature = config.get("group_address_color_temperature") group_address_color_temperature_state = config.get( "group_address_color_temperature_state" ) min_kelvin = config.get("min_kelvin", Light.DEFAULT_MIN_KELVIN) max_kelvin = config.get("max_kelvin", Light.DEFAULT_MAX_KELVIN) ( red_switch, red_switch_state, red_brightness, red_brightness_state, ) = cls.read_color_from_config("red", config) ( green_switch, green_switch_state, green_brightness, green_brightness_state, ) = cls.read_color_from_config("green", config) ( blue_switch, blue_switch_state, blue_brightness, blue_brightness_state, ) = cls.read_color_from_config("blue", config) ( white_switch, white_switch_state, white_brightness, white_brightness_state, ) = cls.read_color_from_config("white", config) return cls( xknx, name, group_address_switch=group_address_switch, group_address_switch_state=group_address_switch_state, group_address_brightness=group_address_brightness, group_address_brightness_state=group_address_brightness_state, group_address_color=group_address_color, group_address_color_state=group_address_color_state, group_address_rgbw=group_address_rgbw, group_address_rgbw_state=group_address_rgbw_state, group_address_tunable_white=group_address_tunable_white, group_address_tunable_white_state=group_address_tunable_white_state, group_address_color_temperature=group_address_color_temperature, group_address_color_temperature_state=group_address_color_temperature_state, group_address_switch_red=red_switch, group_address_switch_red_state=red_switch_state, group_address_brightness_red=red_brightness, group_address_brightness_red_state=red_brightness_state, group_address_switch_green=green_switch, group_address_switch_green_state=green_switch_state, group_address_brightness_green=green_brightness, group_address_brightness_green_state=green_brightness_state, group_address_switch_blue=blue_switch, group_address_switch_blue_state=blue_switch_state, group_address_brightness_blue=blue_brightness, group_address_brightness_blue_state=blue_brightness_state, group_address_switch_white=white_switch, group_address_switch_white_state=white_switch_state, group_address_brightness_white=white_brightness, group_address_brightness_white_state=white_brightness_state, min_kelvin=min_kelvin, max_kelvin=max_kelvin, ) def __str__(self) -> str: """Return object as readable string.""" str_brightness = ( "" if not self.supports_brightness else f' brightness="{self.brightness.group_addr_str()}"' ) str_color = ( "" if not self.supports_color else f' color="{self.color.group_addr_str()}"' ) str_rgbw = ( "" if not self.supports_rgbw else f' rgbw="{self.rgbw.group_addr_str()}"' ) str_tunable_white = ( "" if not self.supports_tunable_white else f' tunable white="{self.tunable_white.group_addr_str()}"' ) str_color_temperature = ( "" if not self.supports_color_temperature else ' color temperature="{}"'.format( self.color_temperature.group_addr_str() ) ) str_red_state = ( "" if not self.red.switch.initialized else f' red_state="{self.red.switch.group_addr_str()}"' ) str_red_brightness = ( "" if not self.red.brightness.initialized else f' red_brightness="{self.red.brightness.group_addr_str()}"' ) str_green_state = ( "" if not self.green.switch.initialized else f' green_state="{self.green.switch.group_addr_str()}"' ) str_green_brightness = ( "" if not self.green.brightness.initialized else f' green_brightness="{self.green.brightness.group_addr_str()}"' ) str_blue_state = ( "" if not self.blue.switch.initialized else f' blue_state="{self.blue.switch.group_addr_str()}"' ) str_blue_brightness = ( "" if not self.blue.brightness.initialized else f' blue_brightness="{self.blue.brightness.group_addr_str()}"' ) str_white_state = ( "" if not self.white.switch.initialized else f' white_state="{self.white.switch.group_addr_str()}"' ) str_white_brightness = ( "" if not self.white.brightness.initialized else f' white_brightness="{self.white.brightness.group_addr_str()}"' ) return '<Light name="{}" ' 'switch="{}"{}{}{}{}{}{}{}{}{}{}{}{}{} />'.format( self.name, self.switch.group_addr_str(), str_brightness, str_color, str_rgbw, str_tunable_white, str_color_temperature, str_red_state, str_red_brightness, str_green_state, str_green_brightness, str_blue_state, str_blue_brightness, str_white_state, str_white_brightness, ) @property def state(self) -> Optional[bool]: """Return the current switch state of the device.""" if self.switch.value is not None: return self.switch.value # type: ignore if any( [ c.switch.value is not None for c in (self.red, self.green, self.blue, self.white) ] ): return any( [c.switch.value for c in (self.red, self.green, self.blue, self.white)] ) return None async def set_on(self) -> None: """Switch light on.""" if self.switch.initialized: await self.switch.on() for color in (self.red, self.green, self.blue, self.white): await color.set_on() async def set_off(self) -> None: """Switch light off.""" if self.switch.initialized: await self.switch.off() for color in (self.red, self.green, self.blue, self.white): await color.set_off() @property def current_brightness(self) -> Optional[int]: """Return current brightness of light.""" return self.brightness.value # type: ignore async def set_brightness(self, brightness: int) -> None: """Set brightness of light.""" if not self.supports_brightness: logger.warning("Dimming not supported for device %s", self.get_name()) return await self.brightness.set(brightness) @property def current_color(self) -> Tuple[Optional[List[int]], Optional[int]]: """ Return current color of light. If the device supports RGBW, get the current RGB+White values instead. """ if self.supports_rgbw: if self.rgbw.initialized: if not self.rgbw.value: return None, None return self.rgbw.value[:3], self.rgbw.value[3] if self.color.initialized: return self.color.value, None # individual RGB addresses - white will return None when it is not initialized colors = [ self.red.brightness.value, self.green.brightness.value, self.blue.brightness.value, ] if None in colors: return None, self.white.brightness.value return colors, self.white.brightness.value async def set_color(self, color: List[int], white: Optional[int] = None) -> None: """ Set color of a light device. If also the white value is given and the device supports RGBW, set all four values. """ if white is not None: if self.supports_rgbw: if self.rgbw.initialized: await self.rgbw.set(list(color) + [white]) return if all( [ c.brightness.initialized for c in (self.red, self.green, self.blue, self.white) ] ): await self.red.brightness.set(color[0]) await self.green.brightness.set(color[1]) await self.blue.brightness.set(color[2]) await self.white.brightness.set(white) return logger.warning("RGBW not supported for device %s", self.get_name()) else: if self.supports_color: if self.color.initialized: await self.color.set(color) return if all( [ c.brightness.initialized for c in (self.red, self.green, self.blue) ] ): await self.red.brightness.set(color[0]) await self.green.brightness.set(color[1]) await self.blue.brightness.set(color[2]) return logger.warning("Colors not supported for device %s", self.get_name()) @property def current_tunable_white(self) -> Optional[int]: """Return current relative color temperature of light.""" return self.tunable_white.value # type: ignore async def set_tunable_white(self, tunable_white: int) -> None: """Set relative color temperature of light.""" if not self.supports_tunable_white: logger.warning("Tunable white not supported for device %s", self.get_name()) return await self.tunable_white.set(tunable_white) @property def current_color_temperature(self) -> Optional[int]: """Return current absolute color temperature of light.""" return self.color_temperature.value # type: ignore async def set_color_temperature(self, color_temperature: int) -> None: """Set absolute color temperature of light.""" if not self.supports_color_temperature: logger.warning( "Absolute Color Temperature not supported for device %s", self.get_name(), ) return await self.color_temperature.set(color_temperature) async def do(self, action: str) -> None: """Execute 'do' commands.""" if action == "on": await self.set_on() elif action == "off": await self.set_off() elif action.startswith("brightness:"): await self.set_brightness(int(action[11:])) elif action.startswith("tunable_white:"): await self.set_tunable_white(int(action[14:])) elif action.startswith("color_temperature:"): await self.set_color_temperature(int(action[18:])) else: logger.warning( "Could not understand action %s for device %s", action, self.get_name() ) async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" for remote_value in self._iter_remote_values(): await remote_value.process(telegram)
class Climate(Device): """Class for managing the climate.""" # pylint: disable=too-many-instance-attributes,invalid-name def __init__(self, xknx, name, group_address_temperature=None, group_address_target_temperature=None, group_address_target_temperature_state=None, group_address_setpoint_shift=None, group_address_setpoint_shift_state=None, setpoint_shift_mode=DEFAULT_SETPOINT_SHIFT_MODE, setpoint_shift_step=DEFAULT_SETPOINT_SHIFT_STEP, setpoint_shift_max=DEFAULT_SETPOINT_SHIFT_MAX, setpoint_shift_min=DEFAULT_SETPOINT_SHIFT_MIN, group_address_on_off=None, group_address_on_off_state=None, on_off_invert=False, min_temp=None, max_temp=None, mode=None, device_updated_cb=None): """Initialize Climate class.""" # pylint: disable=too-many-arguments, too-many-locals, too-many-branches, too-many-statements super().__init__(xknx, name, device_updated_cb) if isinstance(group_address_on_off, (str, int)): group_address_on_off = GroupAddress(group_address_on_off) if isinstance(group_address_on_off_state, (str, int)): group_address_on_off_state = GroupAddress( group_address_on_off_state) self.group_address_on_off = group_address_on_off self.group_address_on_off_state = group_address_on_off_state self.min_temp = min_temp self.max_temp = max_temp self.setpoint_shift_step = setpoint_shift_step self.setpoint_shift_min = setpoint_shift_min self.setpoint_shift_max = setpoint_shift_max self.temperature = RemoteValueTemp( xknx, group_address_state=group_address_temperature, device_name=self.name, after_update_cb=self.after_update) self.target_temperature = RemoteValueTemp( xknx, group_address_target_temperature, group_address_target_temperature_state, device_name=self.name, after_update_cb=self.after_update) if setpoint_shift_mode == SetpointShiftMode.DPT9002: self._setpoint_shift = RemoteValueTemp( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, device_name=self.name, after_update_cb=self.after_update) else: self._setpoint_shift = RemoteValueSetpointShift( xknx, group_address_setpoint_shift, group_address_setpoint_shift_state, device_name=self.name, after_update_cb=self.after_update, setpoint_shift_step=setpoint_shift_step) self.supports_on_off = \ group_address_on_off is not None or \ group_address_on_off_state is not None self.on = RemoteValueSwitch(xknx, group_address_on_off, group_address_on_off_state, device_name=self.name, after_update_cb=self.after_update, invert=on_off_invert) self.mode = mode @classmethod def from_config(cls, xknx, name, config): """Initialize object from configuration structure.""" # pylint: disable=too-many-locals group_address_temperature = \ config.get('group_address_temperature') group_address_target_temperature = \ config.get('group_address_target_temperature') group_address_target_temperature_state = \ config.get('group_address_target_temperature_state') group_address_setpoint_shift = \ config.get('group_address_setpoint_shift') group_address_setpoint_shift_state = \ config.get('group_address_setpoint_shift_state') setpoint_shift_mode = \ config.get('setpoint_shift_mode', DEFAULT_SETPOINT_SHIFT_MODE) setpoint_shift_step = \ config.get('setpoint_shift_step', DEFAULT_SETPOINT_SHIFT_STEP) setpoint_shift_max = \ config.get('setpoint_shift_max', DEFAULT_SETPOINT_SHIFT_MAX) setpoint_shift_min = \ config.get('setpoint_shift_min', DEFAULT_SETPOINT_SHIFT_MIN) group_address_on_off = \ config.get('group_address_on_off') group_address_on_off_state = \ config.get('group_address_on_off_state') on_off_invert = \ config.get('on_off_invert', False) min_temp = config.get('min_temp') max_temp = config.get('max_temp') climate_mode = None if "mode" in config: climate_mode = ClimateMode.from_config(xknx=xknx, name=None, config=config['mode']) return cls( xknx, name, group_address_temperature=group_address_temperature, group_address_target_temperature=group_address_target_temperature, group_address_target_temperature_state= group_address_target_temperature_state, group_address_setpoint_shift=group_address_setpoint_shift, group_address_setpoint_shift_state= group_address_setpoint_shift_state, setpoint_shift_mode=setpoint_shift_mode, setpoint_shift_step=setpoint_shift_step, setpoint_shift_max=setpoint_shift_max, setpoint_shift_min=setpoint_shift_min, group_address_on_off=group_address_on_off, group_address_on_off_state=group_address_on_off_state, on_off_invert=on_off_invert, min_temp=min_temp, max_temp=max_temp, mode=climate_mode) def has_group_address(self, group_address): """Test if device has given group address.""" if self.mode is not None and self.mode.has_group_address( group_address): return True return self.temperature.has_group_address(group_address) or \ self.target_temperature.has_group_address(group_address) or \ self._setpoint_shift.has_group_address(group_address) or \ self.on.has_group_address(group_address) @property def is_on(self): """Return power status.""" # None will return False return bool(self.on.value) async def turn_on(self): """Set power status to on.""" await self.on.on() async def turn_off(self): """Set power status to off.""" await self.on.off() @property def initialized_for_setpoint_shift_calculations(self): """Test if object is initialized for setpoint shift calculations.""" if not self._setpoint_shift.initialized: return False if self._setpoint_shift.value is None: return False if not self.target_temperature.initialized: return False if self.target_temperature.value is None: return False return True @property def temperature_step(self): """Return smallest possible temperature step.""" if self._setpoint_shift.initialized: return self.setpoint_shift_step return DEFAULT_TEMPERATURE_STEP async def set_target_temperature(self, target_temperature): """Send new target temperature or setpoint_shift to KNX bus.""" if self.initialized_for_setpoint_shift_calculations: temperature_delta = target_temperature - self.base_temperature await self.set_setpoint_shift(temperature_delta) else: validated_temp = self.validate_value(target_temperature, self.min_temp, self.max_temp) await self.target_temperature.set(validated_temp) @property def base_temperature(self): """ Return the base temperature. Base temperature is the default temperature (setpoint-shift=0) for the active climate mode. As this value is usually not available via KNX, we have to derive this from the current target temperature and the current set point shift. """ if self.initialized_for_setpoint_shift_calculations: return self.target_temperature.value - self.setpoint_shift return None @property def setpoint_shift(self): """Return current offset from base temperature in Kelvin.""" return self._setpoint_shift.value def validate_value(self, value, min_value, max_value): """Check boundaries of temperature and return valid temperature value.""" if (min_value is not None) and (value < min_value): self.xknx.logger.warning("min value exceeded at %s: %s", self.name, value) return min_value if (max_value is not None) and (value > max_value): self.xknx.logger.warning("max value exceeded at %s: %s", self.name, value) return max_value return value async def set_setpoint_shift(self, offset): """Send new temperature offset to KNX bus.""" validated_offset = self.validate_value(offset, self.setpoint_shift_min, self.setpoint_shift_max) base_temperature = self.base_temperature await self._setpoint_shift.set(validated_offset) # broadcast new target temperature and set internally if self.target_temperature.writable and \ base_temperature is not None: await self.target_temperature.set(base_temperature + self.setpoint_shift) @property def target_temperature_max(self): """Return the highest possible target temperature.""" if self.max_temp is not None: return self.max_temp if self.initialized_for_setpoint_shift_calculations: return self.base_temperature + self.setpoint_shift_max return None @property def target_temperature_min(self): """Return the lowest possible target temperature.""" if self.min_temp is not None: return self.min_temp if self.initialized_for_setpoint_shift_calculations: return self.base_temperature + self.setpoint_shift_min return None async def process_group_write(self, telegram): """Process incoming GROUP WRITE telegram.""" await self.temperature.process(telegram) await self.target_temperature.process(telegram) await self._setpoint_shift.process(telegram) await self.on.process(telegram) if self.mode is not None: await self.mode.process_group_write(telegram) def state_addresses(self): """Return group addresses which should be requested to sync state.""" state_addresses = [] state_addresses.extend(self.temperature.state_addresses()) state_addresses.extend(self.target_temperature.state_addresses()) state_addresses.extend(self._setpoint_shift.state_addresses()) if self.supports_on_off: state_addresses.extend(self.on.state_addresses()) if self.mode is not None: state_addresses.extend(self.mode.state_addresses()) return state_addresses def __str__(self): """Return object as readable string.""" return '<Climate name="{0}" ' \ 'temperature="{1}" ' \ 'target_temperature="{2}" ' \ 'setpoint_shift="{3}" ' \ 'setpoint_shift_step="{4}" ' \ 'setpoint_shift_max="{5}" ' \ 'setpoint_shift_min="{6}" ' \ 'group_address_on_off="{7}" ' \ '/>' \ .format( self.name, self.temperature.group_addr_str(), self.target_temperature.group_addr_str(), self._setpoint_shift.group_addr_str(), self._setpoint_shift.setpoint_shift_step, self.setpoint_shift_max, self.setpoint_shift_min, self.on.group_addr_str()) def __eq__(self, other): """Equal operator.""" return self.__dict__ == other.__dict__
class Cover(Device): """Class for managing a cover.""" DEFAULT_TRAVEL_TIME_DOWN = 22 DEFAULT_TRAVEL_TIME_UP = 22 def __init__( self, xknx: XKNX, name: str, group_address_long: GroupAddressesType | None = None, group_address_short: GroupAddressesType | None = None, group_address_stop: GroupAddressesType | None = None, group_address_position: GroupAddressesType | None = None, group_address_position_state: GroupAddressesType | None = None, group_address_angle: GroupAddressesType | None = None, group_address_angle_state: GroupAddressesType | None = None, group_address_locked_state: GroupAddressesType | None = None, travel_time_down: float = DEFAULT_TRAVEL_TIME_DOWN, travel_time_up: float = DEFAULT_TRAVEL_TIME_UP, invert_position: bool = False, invert_angle: bool = False, device_updated_cb: DeviceCallbackType | None = None, device_class: str | None = None, ): """Initialize Cover class.""" super().__init__(xknx, name, device_updated_cb) # self.after_update for position changes is called after updating the # travelcalculator (in process_group_write and set_*) - angle changes # are updated from RemoteValue objects self.updown = RemoteValueUpDown( xknx, group_address_long, device_name=self.name, after_update_cb=None, invert=invert_position, ) self.step = RemoteValueStep( xknx, group_address_short, device_name=self.name, after_update_cb=self.after_update, invert=invert_position, ) self.stop_ = RemoteValueSwitch( xknx, group_address=group_address_stop, device_name=self.name, after_update_cb=None, ) position_range_from = 100 if invert_position else 0 position_range_to = 0 if invert_position else 100 self.position_current = RemoteValueScaling( xknx, group_address_state=group_address_position_state, device_name=self.name, feature_name="Position", after_update_cb=self._current_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) self.position_target = RemoteValueScaling( xknx, group_address=group_address_position, device_name=self.name, feature_name="Target position", after_update_cb=self._target_position_from_rv, range_from=position_range_from, range_to=position_range_to, ) angle_range_from = 100 if invert_angle else 0 angle_range_to = 0 if invert_angle else 100 self.angle = RemoteValueScaling( xknx, group_address_angle, group_address_angle_state, device_name=self.name, feature_name="Tilt angle", after_update_cb=self.after_update, range_from=angle_range_from, range_to=angle_range_to, ) self.locked = RemoteValueSwitch( xknx, group_address_state=group_address_locked_state, device_name=self.name, feature_name="Locked", after_update_cb=self.after_update, ) self.travel_time_down = travel_time_down self.travel_time_up = travel_time_up self.travelcalculator = TravelCalculator(travel_time_down, travel_time_up) self.travel_direction_tilt: TravelStatus | None = None self.device_class = device_class def _iter_remote_values(self) -> Iterator[RemoteValue[Any, Any]]: """Iterate the devices RemoteValue classes.""" yield self.updown yield self.step yield self.stop_ yield self.position_current yield self.position_target yield self.angle yield self.locked @property def unique_id(self) -> str | None: """Return unique id for this device.""" return f"{self.updown.group_address}" def __str__(self) -> str: """Return object as readable string.""" return ('<Cover name="{}" ' "updown={} " "step={} " "stop_={} " "position_current={} " "position_target={} " "angle={} " "locked={} " 'travel_time_down="{}" ' 'travel_time_up="{}" />'.format( self.name, self.updown.group_addr_str(), self.step.group_addr_str(), self.stop_.group_addr_str(), self.position_current.group_addr_str(), self.position_target.group_addr_str(), self.angle.group_addr_str(), self.locked.group_addr_str(), self.travel_time_down, self.travel_time_up, )) async def set_down(self) -> None: """Move cover down.""" await self.updown.down() self.travelcalculator.start_travel_down() self.travel_direction_tilt = None await self.after_update() async def set_up(self) -> None: """Move cover up.""" await self.updown.up() self.travelcalculator.start_travel_up() self.travel_direction_tilt = None await self.after_update() async def set_short_down(self) -> None: """Move cover short down.""" await self.step.increase() async def set_short_up(self) -> None: """Move cover short up.""" await self.step.decrease() async def stop(self) -> None: """Stop cover.""" if self.stop_.writable: await self.stop_.on() elif self.step.writable: if (self.travel_direction_tilt == TravelStatus.DIRECTION_UP or self.travelcalculator.travel_direction == TravelStatus.DIRECTION_UP): await self.step.decrease() elif (self.travel_direction_tilt == TravelStatus.DIRECTION_DOWN or self.travelcalculator.travel_direction == TravelStatus.DIRECTION_DOWN): await self.step.increase() else: logger.warning("Stop not supported for device %s", self.get_name()) return self.travelcalculator.stop() self.travel_direction_tilt = None await self.after_update() async def set_position(self, position: int) -> None: """Move cover to a desginated postion.""" if not self.position_target.writable: # No direct positioning group address defined # fully open or close is always possible even if current position is not known current_position = self.current_position() if current_position is None: if position == self.travelcalculator.position_open: await self.updown.up() elif position == self.travelcalculator.position_closed: await self.updown.down() else: logger.warning( "Current position unknown. Initialize cover by moving to end position." ) return elif position < current_position: await self.updown.up() elif position > current_position: await self.updown.down() self.travelcalculator.start_travel(position) await self.after_update() else: await self.position_target.set(position) async def _target_position_from_rv(self) -> None: """Update the target postion from RemoteValue (Callback).""" new_target = self.position_target.value if new_target is not None: self.travelcalculator.start_travel(new_target) await self.after_update() async def _current_position_from_rv(self) -> None: """Update the current postion from RemoteValue (Callback).""" position_before_update = self.travelcalculator.current_position() new_position = self.position_current.value if new_position is not None: if self.is_traveling(): self.travelcalculator.update_position(new_position) else: self.travelcalculator.set_position(new_position) if position_before_update != self.travelcalculator.current_position( ): await self.after_update() async def set_angle(self, angle: int) -> None: """Move cover to designated angle.""" if not self.supports_angle: logger.warning("Angle not supported for device %s", self.get_name()) return current_angle = self.current_angle() self.travel_direction_tilt = ( TravelStatus.DIRECTION_DOWN if current_angle is not None and angle >= current_angle else TravelStatus.DIRECTION_UP) await self.angle.set(angle) async def auto_stop_if_necessary(self) -> None: """Do auto stop if necessary.""" # If device does not support auto_positioning, # we have to stop the device when position is reached, # unless device was traveling to fully open # or fully closed state. if (self.supports_stop and not self.position_target.writable and self.position_reached() and not self.is_open() and not self.is_closed()): await self.stop() async def sync(self, wait_for_result: bool = False) -> None: """Read states of device from KNX bus.""" await self.position_current.read_state(wait_for_result=wait_for_result) await self.angle.read_state(wait_for_result=wait_for_result) async def process_group_write(self, telegram: "Telegram") -> None: """Process incoming and outgoing GROUP WRITE telegram.""" # call after_update to account for travelcalculator changes if await self.updown.process(telegram): if (not self.is_opening() and self.updown.value == RemoteValueUpDown.Direction.UP): self.travelcalculator.start_travel_up() await self.after_update() elif (not self.is_closing() and self.updown.value == RemoteValueUpDown.Direction.DOWN): self.travelcalculator.start_travel_down() await self.after_update() # stop from bus if await self.stop_.process(telegram) or await self.step.process( telegram): if self.is_traveling(): self.travelcalculator.stop() await self.after_update() await self.position_current.process(telegram, always_callback=True) await self.position_target.process(telegram) await self.angle.process(telegram) await self.locked.process(telegram) def current_position(self) -> int | None: """Return current position of cover.""" return self.travelcalculator.current_position() def current_angle(self) -> int | None: """Return current tilt angle of cover.""" return self.angle.value def is_locked(self) -> bool | None: """Return if the cover is currently locked for manual movement.""" return self.locked.value def is_traveling(self) -> bool: """Return if cover is traveling at the moment.""" return self.travelcalculator.is_traveling() def position_reached(self) -> bool: """Return if cover has reached its final position.""" return self.travelcalculator.position_reached() def is_open(self) -> bool: """Return if cover is open.""" return self.travelcalculator.is_open() def is_closed(self) -> bool: """Return if cover is closed.""" return self.travelcalculator.is_closed() def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.travelcalculator.is_opening() def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.travelcalculator.is_closing() @property def supports_stop(self) -> bool: """Return if cover supports manual stopping.""" return self.stop_.writable or self.step.writable @property def supports_locked(self) -> bool: """Return if cover supports locking.""" return self.locked.initialized @property def supports_position(self) -> bool: """Return if cover supports direct positioning.""" return self.position_target.initialized @property def supports_angle(self) -> bool: """Return if cover supports tilt angle.""" return self.angle.initialized