From 29a4d61a5dcadb53abdcc9b8e3f0f7935a49a13c Mon Sep 17 00:00:00 2001 From: TB-1993 <109213741+TB-1993@users.noreply.github.com> Date: Tue, 14 Jan 2025 17:28:23 +0000 Subject: [PATCH 1/4] Update #132: Created abstract power module and added Tapo support - Added Tapo power supply support - Created abstract power module - Added power control methods to read Current, Voltage and Power - Updated power control test - BugFix Kasa control where some switches had changed --- examples/configs/example_rack_config.yml | 1 + framework/core/powerControl.py | 40 ++- .../core/powerModules/abstractPowerModule.py | 121 ++++++++ framework/core/powerModules/kasaControl.py | 29 +- framework/core/powerModules/tapoControl.py | 265 ++++++++++++++++++ tests/powerSwitch_tests.py | 22 ++ 6 files changed, 463 insertions(+), 15 deletions(-) create mode 100644 framework/core/powerModules/abstractPowerModule.py create mode 100644 framework/core/powerModules/tapoControl.py diff --git a/examples/configs/example_rack_config.yml b/examples/configs/example_rack_config.yml index b0d9372..c81560a 100644 --- a/examples/configs/example_rack_config.yml +++ b/examples/configs/example_rack_config.yml @@ -87,6 +87,7 @@ rackConfig: # [type: "orvbioS20", ip: "", mac: "", port:"optional", relay:"optional"] # [type: "kasa", ip: "", options:"--plug" ] # <- Plug # [type: "kasa", ip: "", options:"--strip", args:'--index 2' ] # <- Power Strip + # [tyep: "tapo", ip: "", username: "optional", password: "optional", outlet: "optional"] # [type: "hs100", ip:"", port:"optional" ] kara also supports hs100 # [type: "apc", ip:"", username:"", password:"" ] rack apc switch # [type: "olimex", ip:"", port:"optional", relay:"" ] diff --git a/framework/core/powerControl.py b/framework/core/powerControl.py index b8d6c55..e95b053 100644 --- a/framework/core/powerControl.py +++ b/framework/core/powerControl.py @@ -47,6 +47,7 @@ from framework.core.powerModules.apcAos import powerApcAos from framework.core.powerModules.kasaControl import powerKasa +from framework.core.powerModules.tapoControl import powerTapo from framework.core.powerModules.olimex import powerOlimex from framework.core.powerModules.apc import powerAPC from framework.core.powerModules.hs100 import powerHS100 @@ -89,6 +90,8 @@ def __init__(self, log:logModule, config:dict): self.powerSwitch = powerOlimex( log, self.ip, config.get("port"), config.get("relay")) elif type == "kasa": self.powerSwitch = powerKasa( log, **config ) + elif type == "tapo": + self.powerSwitch = powerTapo( log, **config) elif type == "SLP": self.powerSwitch = powerSLP(log, self.ip, config.get("username"), config.get("password"), config.get("outlet_id"),config.get('port',23)) elif type == "none": @@ -112,11 +115,46 @@ def powerOff(self): self.powerOnState = False return result - def reboot(self): self.log.info("reboot") return self.powerRetry(self.powerSwitch.reboot) + def getPowerLevel(self): + """Retreive the current power draw of the device. + + Returns: + float: Power draw in Watts. + + Raises: + RuntimeError: if powerSwitch type doesn't support power readings. + """ + self.log.debug("Retrieving current Power level") + return self.powerRetry(self.powerSwitch.getPowerLevel) + + def getVoltageLevel(self): + """Retreive the current Voltage draw of the device. + + Returns: + float: Voltage draw in Volts. + + Raises: + RuntimeError: if powerSwitch type doesn't support Voltage readings. + """ + self.log.debug("Retrieving current Voltage level") + return self.powerRetry(self.powerSwitch.getVoltageLevel) + + def getCurrentLevel(self): + """Retreive the current draw of the device. + + Returns: + float: Current draw in Amps. + + Raises: + RuntimeError: if powerSwitch type doesn't support current readings. + """ + self.log.debug("Retrieving current Voltage level") + return self.powerRetry(self.powerSwitch.getCurrentLevel) + def powerRetry(self, powerMethod): """ Performs the passed powerMethod and retries it if it fails diff --git a/framework/core/powerModules/abstractPowerModule.py b/framework/core/powerModules/abstractPowerModule.py new file mode 100644 index 0000000..7df02e4 --- /dev/null +++ b/framework/core/powerModules/abstractPowerModule.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +#/* ***************************************************************************** +# * +# * If not stated otherwise in this file or this component's LICENSE file the +# * following copyright and licenses apply: +# * +# * Copyright 2023 RDK Management +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * +# http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# * +#* ****************************************************************************** +#* +#* ** Project : RAFT +#* ** @addtogroup : core.powerModules +#* ** @date : 14/01/2025 +#* ** +#* ** @brief : Abstract Power Module +#* ** +#* ***************************************************************************** + +from abc import ABCMeta, abstractmethod +import time + +from framework.core.logModule import logModule + + +class PowerModuleInterface(metaclass=ABCMeta): + + def __init__(cls,logger: logModule): + cls._log = logger + cls._is_on = False + + @property + def is_on(cls): + """Boolean for the current power state of the powerswitch/outlet + True if the powerswitch/outlet is powered on. + """ + return cls._is_on + + @property + def is_off(cls): + """Boolean for the current power state of the powerswitch/outlet + True if the powerswitch/outlet is powered off. + """ + if cls.is_on: + return False + return True + + @abstractmethod + def powerOn(cls) -> bool: + """Turn on the powerswitch/outlet. + + Returns: + bool: True if successfully switched on. + """ + pass + + @abstractmethod + def powerOff(cls) -> bool: + """Turn off the powerswitch/outlet. + + Returns: + bool: True if successfully switched off. + """ + pass + + def reboot(self) -> bool: + """Power cycle the powerswitch/outlet. + + Returns: + bool: True if successfully turned back on. + """ + if self.is_on: + self.powerOff() + time.sleep(1) + self.powerOn() + return self.is_on + + def getPowerLevel(cls) -> float: + """Retrieve the current power draw from the powerswitch/outlet. + + Returns: + float: Power level in Watts. + + Raises: + RuntimeError: When method cannot be implemented for the powerswitch type. + """ + raise RuntimeError('Power monitoring is not supported by this power module: [{}]'.format(cls.__class__.__name__)) + + def getVoltageLevel(cls) -> float: + """Retrieve the current Voltage draw from the powerswitch/outlet. + + Returns: + float: Voltage level in Volts. + + Raises: + RuntimeError: When method cannot be implemented for the powerswitch type. + """ + raise RuntimeError('Voltage monitoring is not supported by this power module: [{}]'.format(cls.__class__.__name__)) + + def getCurrentLevel(cls) -> float: + """Retrieve the current power draw from the powerswitch/outlet. + + Returns: + float: Current level in Amps. + + Raises: + RuntimeError: When method cannot be implemented for the powerswitch type. + """ + raise RuntimeError('Current monitoring is not supported by this power module: [{}]'.format(cls.__class__.__name__)) \ No newline at end of file diff --git a/framework/core/powerModules/kasaControl.py b/framework/core/powerModules/kasaControl.py index e8e0880..a1add16 100644 --- a/framework/core/powerModules/kasaControl.py +++ b/framework/core/powerModules/kasaControl.py @@ -59,8 +59,9 @@ import subprocess from framework.core.logModule import logModule +from framework.core.powerModules.abstractPowerModule import PowerModuleInterface -class powerKasa(): +class powerKasa(PowerModuleInterface): """Kasa power switch controller supports """ @@ -81,7 +82,7 @@ def __init__( self, log:logModule, ip:str, args:str=None, options:str=None, **kw options ([str], optional): [options]. Defaults to None, which translates to "--plug" kwargs ([dict]): [any other args] """ - self.log = log + super().__init__(log) self.is_on = False self.is_off = False self.slotIndex=0 @@ -90,7 +91,7 @@ def __init__( self, log:logModule, ip:str, args:str=None, options:str=None, **kw #args = config.get("args") #options = config.get("options") if options == None: - options = "--plug" + options = "--type plug" if args == None: args = "" else: @@ -155,9 +156,9 @@ def performCommand(self, command, noOptions = False, noArgs = False): extension += " {}".format(self.args) # kasa [OPTIONS] COMMAND [ARGS]... command = self.split_with_quotes( "kasa " + extension ) - self.log.debug( "Command: {}".format(command)) + self._log.debug( "Command: {}".format(command)) data = subprocess.run(command, stdout=subprocess.PIPE, text=True) - self.log.debug(data.stdout) + self._log.debug(data.stdout) return data.stdout def powerOff(self): @@ -173,7 +174,7 @@ def powerOff(self): self.performCommand("off") self.__getstate__() if self.is_off == False: - self.log.error(" Power Off Failed") + self._log.error(" Power Off Failed") return self.is_off def powerOn(self): @@ -189,7 +190,7 @@ def powerOn(self): self.performCommand("on") self.__getstate__() if self.is_on == False: - self.log.error(" Power On Failed") + self._log.error(" Power On Failed") return self.is_on def __getstate__(self): @@ -211,33 +212,33 @@ def __getstate__(self): elif line[:3] == "OFF": powerState.append("OFF") if len(powerState) != 0: - self.log.debug(powerState) + self._log.debug(powerState) # Check if this strip is off if powerState[0] == "OFF": self.is_on = False self.is_off = True - self.log.debug("Device state: OFF") + self._log.debug("Device state: OFF") return # Check if the this socket is off. if powerState[self.slotIndex+1] == "OFF": self.is_on = False self.is_off = True - self.log.debug("Slot state: OFF") + self._log.debug("Slot state: OFF") else: self.is_on = True self.is_off = False - self.log.debug("Slot state: ON") + self._log.debug("Slot state: ON") else: result = self.performCommand("state") # | grep 'Device state' | cut -d ' ' -f 3 - if "Device state: OFF" in result: + if "Device state: False" in result: self.is_on = False self.is_off = True - self.log.debug("Device state: OFF") + self._log.debug("Device state: OFF") else: self.is_on = True self.is_off = False - self.log.debug("Device state: ON") + self._log.debug("Device state: ON") def reboot(self): """ diff --git a/framework/core/powerModules/tapoControl.py b/framework/core/powerModules/tapoControl.py new file mode 100644 index 0000000..39d83a2 --- /dev/null +++ b/framework/core/powerModules/tapoControl.py @@ -0,0 +1,265 @@ +#!/usr/bin/env python3 +#** ***************************************************************************** +# * +# * If not stated otherwise in this file or this component's LICENSE file the +# * following copyright and licenses apply: +# * +# * Copyright 2023 RDK Management +# * +# * Licensed under the Apache License, Version 2.0 (the "License"); +# * you may not use this file except in compliance with the License. +# * You may obtain a copy of the License at +# * +# * +# http://www.apache.org/licenses/LICENSE-2.0 +# * +# * Unless required by applicable law or agreed to in writing, software +# * distributed under the License is distributed on an "AS IS" BASIS, +# * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# * See the License for the specific language governing permissions and +# * limitations under the License. +# * +#* ****************************************************************************** +#* +#* ** Project : RAFT +#* ** @addtogroup : core.powerModules +#* ** @date : 14/01/2025 +#* ** +#* ** @brief : Power On and Off TAPO power switches +#* ** +# +# https://github.com/python-kasa/python-kasa +# # Supported Kasa devices +# Plugs: EP10, EP251, HS1002, HS103, HS105, HS110, KP100, KP105, KP115, KP125, KP125M1, KP401 +# Power Strips: EP40, EP40M1, HS107, HS300, KP200, KP303, KP400 +# Wall Switches: ES20M, HS2002, HS210, HS2202, KP405, KS200, KS200M, KS2051, KS220, KS220M, KS2251, KS230, KS2401 +# Supported Tapo1 devices +# Plugs: P100, P110, P110M, P115, P125M, P135, TP15 +# Power Strips: P210M, P300, P304M, P306, TP25 +# Wall Switches: S210, S220, S500D, S505, S505D +# +# TODO: Had issues with calling the python library directly it has comms errors +# To get round this issue and to get this in, since kasa command line tool works +# Swap the interface to use that instead +# This implementation is a hack to get TAPO support. +# The kasaControl should be reimplemented to support both Kasa and TAPO +#* ****************************************************************************** + +import json +import re +import subprocess +import time + +from framework.core.logModule import logModule +from framework.core.powerModules.abstractPowerModule import PowerModuleInterface + +class powerTapo(PowerModuleInterface): + + """Tapo power switch controller supports + """ + + def __init__( self, log:logModule, ip:str, outlet:str = None, **kwargs ): + """ + Tapo module based on kasa library. + TODO: Reintegrate this with the powerKasa module. + + Args: + log ([logModule]): [log module] + ip ([str]): [ip] + outlet ([int], optional): Outlet number for power strips. Defaults to None. + kwargs ([dict]): [any other args] + """ + super().__init__(log) + self._is_on = False + self._outlet = None + self.ip = ip + self._username = kwargs.get("username", None) + self._password = kwargs.get("password", None) + if outlet: + self._outlet=str(outlet) + self._device_type = None + self._encryption_type = None + self._discover_device() + self._get_state() + + def _performCommand(self, command, json = False, append_args:list = []): + """ + Perform a command. + + Args: + command (str): The command to execute. + json (bool): Add the --json option to the command. + Retrieves the data in json string format. + Default is False. + append_args (list): Extra arguments to add on the end of the command. + Defaults to an empty list. + Returns: + str: The command output. + """ + command_list = ["kasa", "--host", self.ip] + if json: + command_list.append("--json") + if self._username: + command_list.append("--username") + command_list.append(self._username) + if self._password: + command_list.append("--password") + command_list.append(self._password) + if self._device_type != "UNKNOWN" and self._encryption_type: + command_list.append("--device-family") + command_list.append(self._device_type) + command_list.append("--encrypt-type") + command_list.append(self._encryption_type) + else: + if self._outlet: + command_list.append("--type") + command_list.append("strip") + else: + command_list.append("--type") + command_list.append("plug") + command_list.append(command) + for arg in append_args: + command_list.append(arg) + self._log.debug( "Command: {}".format(" ".join(command_list))) + data = subprocess.run(command_list, stdout=subprocess.PIPE, text=True) + self._log.debug(data.stdout) + return data.stdout + + def powerOff(self): + """ + Turn off the device. + + Returns: + bool: True if the operation is successful, False otherwise. + """ + self._get_state() + if self.is_off: + return True + if self._outlet: + self._performCommand("off", append_args=["--index", str(self._outlet)]) + else: + self._performCommand("off") + self._get_state() + if self.is_off == False: + self._log.error(" Power Off Failed") + return self.is_off + + def powerOn(self): + """ + Turn on the device. + + Returns: + bool: True if the operation is successful, False otherwise. + """ + self._get_state() + if self.is_on: + return True + if self._outlet: + self._performCommand("on", append_args=["--index", str(self._outlet)]) + self._performCommand("on") + self._get_state() + if self.is_on == False: + self._log.error(" Power On Failed") + return self.is_on + + def _get_state(self): + """Get the state of the device. + """ + result = self._performCommand("state") + if self._outlet: + # We have a strip look at the status of the strip, and check the index and the device state + #Device state: ON + #== Plugs == + #* Socket 'Plug 1' state: ON on_since: 2022-01-26 12:17:41.423468 + #* Socket 'Plug 2' state: OFF on_since: None + #* Socket 'Plug 3' state: OFF on_since: None + result = self._performCommand("state", noArgs=True) + state = result.split("state: ") + powerState = [] + for line in state: + if line[:2] == "ON": + powerState.append("ON") + elif line[:3] == "OFF": + powerState.append("OFF") + if len(powerState) != 0: + self._log.debug(powerState) + # Check if this strip is off + if powerState[0] == "OFF": + self._is_on = False + self._log.debug("Device state: OFF") + return + # Check if the this socket is off. + if powerState[self.slotIndex+1] == "OFF": + self._is_on = False + self._log.debug("Slot state: OFF") + else: + self._is_on = True + self._log.debug("Slot state: ON") + else: + # | grep 'Device state' | cut -d ' ' -f 3 + if "Device state: False" in result: + self._is_on = False + self._log.debug("Device state: OFF") + else: + self._is_on = True + self._log.debug("Device state: ON") + + + def _discover_device(self): + command = ["kasa", "--json", "--target", str(self.ip)] + if self._username: + command.append("--username") + command.append(self._username) + if self._password: + command.append("--password") + command.append(self._password) + command.append("discover") + result = subprocess.run(command, + stdout=subprocess.PIPE, + check=True, + text=True) + result = json.loads(result.stdout) + if result.get(self.ip): + result = result.get(self.ip) + else: + self._device_type = "UNKNOWN" + + if result.get("info"): + info = result.get("info") + self._device_type = info.get("type", "UNKNOWN") + elif result.get("system"): + system = result.get("system") + if info:=system.get("get_sysinfo"): + self._device_type = info.get("mic_type", "UNKNOWN") + else: + self._device_type = "UNKNOWN" + else: + self._device_type = "UNKNOWN" + self._encryption_type = self._get_encryption_type() + + def _get_encryption_type(self): + command = ["kasa", "--target", self.ip, "discover"] + result = subprocess.run(command, + check=True, + stdout=subprocess.PIPE, + text=True) + found = re.search(r"Encrypt Type:\s+(.*)$", result.stdout,re.M) + if found: + return found.group(1) + return None + + def getPowerLevel(self): + if self._outlet: + # TODO: implement this for a powerstrip + # result = self._performCommand("emeter", + # json=True, + # append_args=["--index", str(self._outlet)]) + raise RuntimeError("Power monitoring is not yet supported for Tapo strips") + else: + result = self._performCommand("emeter", json=True) + result = json.loads(result) + millewatt = result.get('power_mw') + if millewatt: + power = int(millewatt) / 1000 + return power + raise KeyError("The dictionary return for the Tapo device did is not formed as expected.") diff --git a/tests/powerSwitch_tests.py b/tests/powerSwitch_tests.py index dd54c0d..4334465 100755 --- a/tests/powerSwitch_tests.py +++ b/tests/powerSwitch_tests.py @@ -76,8 +76,30 @@ utils.wait(5) + log.info("Testing getPowerLevel") + try: + result = power.getPowerLevel() + log.info("Current Power: [{}W]".format(result)) + except RuntimeError as e: + log.info(e) + + log.info("Testing getVoltageLevel") + try: + result = power.getVoltageLevel() + log.info("Current Voltage: [{}V]".format(result)) + except RuntimeError as e: + log.info(e) + + log.info("Testing getCurrentLevel") + try: + result = power.getCurrentLevel() + log.info("Current current: [{}A]".format(result)) + except RuntimeError as e: + log.info(e) + log.info("Testing Power Off") power.powerOff() + log.info("Test complete") From 08a7e61237dcc00316cabf65a27f0b9cf24b7a20 Mon Sep 17 00:00:00 2001 From: TB-1993 <109213741+TB-1993@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:24:41 +0000 Subject: [PATCH 2/4] Fix #132: Updated example_rack_config to show username password are required for Tapo --- examples/configs/example_rack_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/configs/example_rack_config.yml b/examples/configs/example_rack_config.yml index c81560a..0b28048 100644 --- a/examples/configs/example_rack_config.yml +++ b/examples/configs/example_rack_config.yml @@ -87,7 +87,7 @@ rackConfig: # [type: "orvbioS20", ip: "", mac: "", port:"optional", relay:"optional"] # [type: "kasa", ip: "", options:"--plug" ] # <- Plug # [type: "kasa", ip: "", options:"--strip", args:'--index 2' ] # <- Power Strip - # [tyep: "tapo", ip: "", username: "optional", password: "optional", outlet: "optional"] + # [tyep: "tapo", ip: "", username: "", password: "", outlet: "optional"] # [type: "hs100", ip:"", port:"optional" ] kara also supports hs100 # [type: "apc", ip:"", username:"", password:"" ] rack apc switch # [type: "olimex", ip:"", port:"optional", relay:"" ] From 980f4f7ae6e4c0706e9a2c01dd327aef69512067 Mon Sep 17 00:00:00 2001 From: TB-1993 <109213741+TB-1993@users.noreply.github.com> Date: Mon, 27 Jan 2025 10:39:36 +0000 Subject: [PATCH 3/4] Fix #132: Fixed typos --- examples/configs/example_rack_config.yml | 2 +- framework/core/powerControl.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/configs/example_rack_config.yml b/examples/configs/example_rack_config.yml index 0b28048..eb9b2a0 100644 --- a/examples/configs/example_rack_config.yml +++ b/examples/configs/example_rack_config.yml @@ -87,7 +87,7 @@ rackConfig: # [type: "orvbioS20", ip: "", mac: "", port:"optional", relay:"optional"] # [type: "kasa", ip: "", options:"--plug" ] # <- Plug # [type: "kasa", ip: "", options:"--strip", args:'--index 2' ] # <- Power Strip - # [tyep: "tapo", ip: "", username: "", password: "", outlet: "optional"] + # [type: "tapo", ip: "", username: "", password: "", outlet: "optional"] # [type: "hs100", ip:"", port:"optional" ] kara also supports hs100 # [type: "apc", ip:"", username:"", password:"" ] rack apc switch # [type: "olimex", ip:"", port:"optional", relay:"" ] diff --git a/framework/core/powerControl.py b/framework/core/powerControl.py index e95b053..e0e7c5c 100644 --- a/framework/core/powerControl.py +++ b/framework/core/powerControl.py @@ -120,7 +120,7 @@ def reboot(self): return self.powerRetry(self.powerSwitch.reboot) def getPowerLevel(self): - """Retreive the current power draw of the device. + """Retrieve the current power draw of the device. Returns: float: Power draw in Watts. @@ -132,7 +132,7 @@ def getPowerLevel(self): return self.powerRetry(self.powerSwitch.getPowerLevel) def getVoltageLevel(self): - """Retreive the current Voltage draw of the device. + """Retrieve the current Voltage draw of the device. Returns: float: Voltage draw in Volts. @@ -144,7 +144,7 @@ def getVoltageLevel(self): return self.powerRetry(self.powerSwitch.getVoltageLevel) def getCurrentLevel(self): - """Retreive the current draw of the device. + """Retrieve the current draw of the device. Returns: float: Current draw in Amps. From 6352b22c25cdeacaa87618c06871012513c2c1dc Mon Sep 17 00:00:00 2001 From: TB-1993 <109213741+TB-1993@users.noreply.github.com> Date: Thu, 30 Jan 2025 09:24:21 +0000 Subject: [PATCH 4/4] Bumped CHANGELOG --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5ba6ec..ae63b95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,29 @@ All notable changes to this project will be documented in this file. Dates are d Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). +#### [1.3.0](https://github.com/rdkcentral/python_raft/compare/1.2.2...1.3.0) + +- Update #132: Created abstract power module and added Tapo support [`#135`](https://github.com/rdkcentral/python_raft/pull/135) +- Release 1.2.2: master -> develop [`#143`](https://github.com/rdkcentral/python_raft/pull/143) +- Fix #132: Fixed typos [`#132`](https://github.com/rdkcentral/python_raft/issues/132) +- Fix #132: Updated example_rack_config to show username password are [`#132`](https://github.com/rdkcentral/python_raft/issues/132) + +#### [1.2.2](https://github.com/rdkcentral/python_raft/compare/1.2.1...1.2.2) + +> 28 January 2025 + +- Feature/gh136 ping test output [`#142`](https://github.com/rdkcentral/python_raft/pull/142) +- Release 1.2.1 master -> develop [`#141`](https://github.com/rdkcentral/python_raft/pull/141) +- change self.output in pingTestOnly() with teh actual command output result[0] [`809f674`](https://github.com/rdkcentral/python_raft/commit/809f6742594e6b780a5953f8fa28a20c2411416f) + #### [1.2.1](https://github.com/rdkcentral/python_raft/compare/1.2.0...1.2.1) +> 27 January 2025 + +- Release 1.2.1 [`#140`](https://github.com/rdkcentral/python_raft/pull/140) +- Release 1.1.2 [`#139`](https://github.com/rdkcentral/python_raft/pull/139) - Bugfix #137: Corrected telnet usage to follow updated telnet class [`#137`](https://github.com/rdkcentral/python_raft/issues/137) +- Bumped changelog [`b3086d1`](https://github.com/rdkcentral/python_raft/commit/b3086d15bc9b8131334cc78f4f2fb7eb3f4f4b91) - Merge tag '1.2.0' into develop [`499b9b1`](https://github.com/rdkcentral/python_raft/commit/499b9b1eb8c60531f9617316615b984c92b1780a) #### [1.2.0](https://github.com/rdkcentral/python_raft/compare/1.1.2...1.2.0)