Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
e544fc0
Add support for TAPO devices
MarekSzczypinski Mar 8, 2025
f63c17e
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Apr 9, 2025
e52eeef
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski May 4, 2025
fec9c33
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski May 4, 2025
a9b75e0
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jul 7, 2025
783fa10
Fix regression
MarekSzczypinski Jul 7, 2025
e87bafc
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jul 8, 2025
c8e31fc
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jul 14, 2025
432aefc
refactor: directly test kasa version in TAPO power model
MarekSzczypinski Jul 15, 2025
d1db59e
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jul 15, 2025
db58078
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Aug 16, 2025
c60c64a
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Aug 27, 2025
3316dba
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Sep 2, 2025
154dc57
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Sep 13, 2025
87eb7a1
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Sep 21, 2025
726c138
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Sep 28, 2025
fa6fbb7
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Oct 2, 2025
2ad2da6
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Oct 9, 2025
c31d2dc
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Oct 12, 2025
956b526
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Oct 16, 2025
aa3c471
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Oct 21, 2025
9ece3ba
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 6, 2025
6e70e3e
fix(tapo): add pylint disables for python-kasa version compatibility
MarekSzczypinski Nov 11, 2025
143e8ad
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 13, 2025
9fed2f5
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 14, 2025
fde21e3
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 17, 2025
4135084
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 19, 2025
d433b07
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Nov 25, 2025
b43cbaf
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Dec 1, 2025
5161fb4
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Dec 2, 2025
e3a1b1e
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Dec 17, 2025
500b931
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Dec 18, 2025
ea469c8
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jan 3, 2026
c53f56c
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jan 10, 2026
e96ca14
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jan 19, 2026
f7088ef
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Jan 30, 2026
efdb5a9
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Feb 1, 2026
68a266c
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski Feb 3, 2026
f3c2fa7
refactor(tapo): unit tests refactored, typo fixed
MarekSzczypinski Feb 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions doc/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ Currently available are:
<https://github.com/labgrid-project/labgrid/blob/master/labgrid/driver/power/simplerest.py>`__
for details.

``tapo``
Controls *Tapo power strips and single socket devices* via `python-kasa
<https://github.com/python-kasa/python-kasa>`_.
Requires valid TP-Link/TAPO cloud credentials to work.
See the `docstring in the module
<https://github.com/labgrid-project/labgrid/blob/master/labgrid/driver/power/tapo.py>`__
for details.

``tinycontrol``
Controls a tinycontrol.eu IP Power Socket via HTTP.
It was tested on the *6G10A v2* model.
Expand Down
108 changes: 108 additions & 0 deletions labgrid/driver/power/tapo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
"""Driver for controlling TP-Link Tapo smart plugs and power strips.

This module provides functionality to control TP-Link Tapo smart power devices through
the kasa library. It supports both single socket devices (like P100) and multi-socket
power strips (like P300).

Features:
- Environment-based authentication using KASA_USERNAME and KASA_PASSWORD
- Support for both single and multi-socket devices

Requirements:
- Valid TP-Link cloud credentials (username/password)
"""

import asyncio
import os

import kasa
from kasa import Credentials, Device, DeviceConfig, DeviceConnectionParameters, DeviceEncryptionType, DeviceFamily
from packaging.version import parse


def _using_old_kasa_api() -> bool:
target_kasa_version = parse("0.10.0")
current_kasa_version = parse(kasa.__version__)
return current_kasa_version < target_kasa_version


def _get_credentials() -> Credentials:
username = os.environ.get("KASA_USERNAME")
password = os.environ.get("KASA_PASSWORD")
if username is None or password is None:
raise EnvironmentError("KASA_USERNAME or KASA_PASSWORD environment variable not set")
return Credentials(username=username, password=password)


def _get_connection_type() -> DeviceConnectionParameters:
# API changed between versions 0.9.1 and 0.10.0 of python-kasa
if _using_old_kasa_api():
return DeviceConnectionParameters(
device_family=DeviceFamily.SmartTapoPlug,
encryption_type=DeviceEncryptionType.Klap,
https=False,
login_version=2,
)
# http_port parameter exists in new kasa (>= 0.10.0) API but not in old versions (<= 0.9.1)
# pylint: disable-next=unexpected-keyword-arg
return DeviceConnectionParameters(
device_family=DeviceFamily.SmartTapoPlug,
encryption_type=DeviceEncryptionType.Klap,
https=False,
login_version=2,
http_port=80,
)


def _get_device_config(host: str) -> DeviceConfig:
# Same as with `_get_connection_type` - python-kasa API changed
if _using_old_kasa_api():
# uses_http parameter exists in old kasa (<= 0.9.1) API but not in new versions (>= 0.10.0)
# pylint: disable-next=unexpected-keyword-arg
return DeviceConfig(
host=host, credentials=_get_credentials(), connection_type=_get_connection_type(), uses_http=True
) # type: ignore[call-arg]
return DeviceConfig(host=host, credentials=_get_credentials(), connection_type=_get_connection_type())


async def _power_set(host: str, port: str, index: str, value: bool) -> None:
"""We embed the coroutines in an `async` function to minimise calls to `asyncio.run`"""
assert port is None
index = int(index)
device = await Device.connect(config=_get_device_config(host))
await device.update()

if device.children:
assert len(device.children) > index, "Trying to access non-existent plug socket on device"

target = device if not device.children else device.children[index]
if value:
await target.turn_on()
else:
await target.turn_off()
await device.disconnect()


def power_set(host: str, port: str, index: str, value: bool) -> None:
asyncio.run(_power_set(host, port, index, value))


async def _power_get(host: str, port: str, index: str) -> bool:
assert port is None
index = int(index)
device = await Device.connect(config=_get_device_config(host))
await device.update()

pwr_state: bool
# If the device has no children, it is a single plug socket
if not device.children:
pwr_state = device.is_on
else:
assert len(device.children) > index, "Trying to access non-existent plug socket on device"
pwr_state = device.children[index].is_on
await device.disconnect()
return pwr_state


def power_get(host: str, port: str, index: str) -> bool:
return asyncio.run(_power_get(host, port, index))
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ include = [
"labgrid/driver/power/gude8031.py",
"labgrid/driver/power/pe6216.py",
"labgrid/driver/power/shelly_gen2.py",
"labgrid/driver/power/tapo.py",
"labgrid/driver/rawnetworkinterfacedriver.py",
"labgrid/protocol/**/*.py",
"labgrid/remote/**/*.py",
Expand Down
4 changes: 4 additions & 0 deletions tests/test_powerdriver.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,10 @@ def test_import_backend_tplink(self):
pytest.importorskip("kasa")
import labgrid.driver.power.tplink

def test_import_backend_tapo(self):
pytest.importorskip("kasa")
import labgrid.driver.power.tapo

def test_import_backend_siglent(self):
pytest.importorskip("vxi11")
import labgrid.driver.power.siglent
Expand Down
130 changes: 130 additions & 0 deletions tests/test_tapo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import os
from unittest.mock import AsyncMock, patch

import pytest

from labgrid.driver.power.tapo import _get_credentials, power_get, power_set


@pytest.fixture
def mock_device_strip():
device = AsyncMock()
device.children = [AsyncMock(is_on=True), AsyncMock(is_on=False), AsyncMock(is_on=True)]
return device


@pytest.fixture
def mock_device_single_plug():
device = AsyncMock()
device.children = []
return device


@pytest.fixture
def mock_env():
os.environ["KASA_USERNAME"] = "test_user"
os.environ["KASA_PASSWORD"] = "test_pass"
yield
del os.environ["KASA_USERNAME"]
del os.environ["KASA_PASSWORD"]


class TestTapoPowerDriver:
def test_get_credentials_should_raise_value_error_when_credentials_missing(self):
# Save existing environment variables
saved_username = os.environ.pop("KASA_USERNAME", None)
saved_password = os.environ.pop("KASA_PASSWORD", None)

try:
with pytest.raises(EnvironmentError, match="KASA_USERNAME or KASA_PASSWORD environment variable not set"):
_get_credentials()
finally:
# Restore environment variables if they existed
if saved_username is not None:
os.environ["KASA_USERNAME"] = saved_username
if saved_password is not None:
os.environ["KASA_PASSWORD"] = saved_password

def test_credentials_valid(self, mock_env):
creds = _get_credentials()
assert creds.username == "test_user"
assert creds.password == "test_pass"

def test_power_get_single_plug_turn_on(self, mock_device_single_plug, mock_env):
mock_device_single_plug.is_on = True

with patch("kasa.Device.connect", return_value=mock_device_single_plug):
result = power_get("192.168.1.100", None, "0")
assert result is True

def test_power_get_single_plug_turn_off(self, mock_device_single_plug, mock_env):
mock_device_single_plug.is_on = False

with patch("kasa.Device.connect", return_value=mock_device_single_plug):
result = power_get("192.168.1.100", None, "0")
assert result is False

def test_power_get_single_plug_should_not_care_for_index(self, mock_device_single_plug, mock_env):
invalid_index_ignored = "7"
mock_device_single_plug.is_on = True

with patch("kasa.Device.connect", return_value=mock_device_single_plug):
result = power_get("192.168.1.100", None, invalid_index_ignored)
assert result is True

def test_power_set_single_plug_turn_on(self, mock_device_single_plug, mock_env):
mock_device_single_plug.is_on = False
with patch("kasa.Device.connect", return_value=mock_device_single_plug):
power_set("192.168.1.100", None, "0", True)
mock_device_single_plug.turn_on.assert_called_once()

def test_power_set_single_plug_turn_off(self, mock_device_single_plug, mock_env):
mock_device_single_plug.is_on = True
with patch("kasa.Device.connect", return_value=mock_device_single_plug):
power_set("192.168.1.100", None, "0", False)
mock_device_single_plug.turn_off.assert_called_once()

def test_power_get_strip_valid_socket(self, mock_device_strip, mock_env):
with patch("kasa.Device.connect", return_value=mock_device_strip):
# Test first outlet (on)
result = power_get("192.168.1.100", None, "0")
assert result is True

# Test second outlet (off)
result = power_get("192.168.1.100", None, "1")
assert result is False

# Test third outlet (on)
result = power_get("192.168.1.100", None, "2")
assert result is True

def test_power_set_strip_valid_socket(self, mock_device_strip, mock_env):
with patch("kasa.Device.connect", return_value=mock_device_strip):
power_set("192.168.1.100", None, "0", False)
mock_device_strip.children[0].turn_off.assert_called_once()

power_set("192.168.1.100", None, "1", True)
mock_device_strip.children[1].turn_on.assert_called_once()

def test_power_get_should_raise_assertion_error_when_invalid_index_strip(self, mock_device_strip, mock_env):
invalid_socket = "5"
with patch("kasa.Device.connect", return_value=mock_device_strip), \
pytest.raises(AssertionError, match="Trying to access non-existent plug socket"):
power_get("192.168.1.100", None, invalid_socket)

def test_power_set_should_raise_assertion_error_when_invalid_index_strip(self, mock_device_strip, mock_env):
invalid_socket = "5"
with patch("kasa.Device.connect", return_value=mock_device_strip), \
pytest.raises(AssertionError, match="Trying to access non-existent plug socket"):
power_set("192.168.1.100", None, invalid_socket, True)

def test_port_not_none_strip(self, mock_device_strip):
with patch("kasa.Device.connect", return_value=mock_device_strip), \
pytest.raises(AssertionError):
power_get("192.168.1.100", "8080", "0")

def test_port_not_none_single_socket(self, mock_device_single_plug):
mock_device_single_plug.is_on = True
with patch("kasa.Device.connect", return_value=mock_device_single_plug), \
pytest.raises(AssertionError):
power_get("192.168.1.100", "8080", "0")
Loading