-
Notifications
You must be signed in to change notification settings - Fork 210
driver/power: Add support for TAPO devices #1630
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
MarekSzczypinski
wants to merge
10
commits into
labgrid-project:master
Choose a base branch
from
MarekSzczypinski:feature/add-tapo-support
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+261
−0
Open
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
e544fc0
Add support for TAPO devices
MarekSzczypinski f63c17e
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski e52eeef
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski fec9c33
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski a9b75e0
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski 783fa10
Fix regression
MarekSzczypinski e87bafc
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski c8e31fc
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski 432aefc
refactor: directly test kasa version in TAPO power model
MarekSzczypinski d1db59e
Merge branch 'master' into feature/add-tapo-support
MarekSzczypinski File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
"""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 sys | ||
|
||
from kasa import Credentials, Device, DeviceConfig, DeviceConnectionParameters, DeviceEncryptionType, DeviceFamily | ||
|
||
|
||
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: | ||
# Somewhere between python-kasa 0.7.7 and 0.10.2 the API changed | ||
# Labgrid on Python <= 3.10 uses python-kasa 0.7.7 | ||
# Labgrid on Python >= 3.11 uses python-kasa 0.10.2 | ||
if sys.version_info < (3, 11): | ||
return DeviceConnectionParameters( | ||
device_family=DeviceFamily.SmartTapoPlug, | ||
encryption_type=DeviceEncryptionType.Klap, | ||
https=False, | ||
login_version=2, | ||
) | ||
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 sys.version_info < (3, 11): | ||
return DeviceConfig( | ||
host=host, credentials=_get_credentials(), connection_type=_get_connection_type(), uses_http=True | ||
) | ||
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-existant 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-existant 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)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,142 @@ | ||
import os | ||
from unittest.mock import AsyncMock, patch | ||
|
||
import pytest | ||
|
||
from labgrid.driver.power.tapo import _get_credentials, _power_get, _power_set, power_get | ||
|
||
|
||
@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' | ||
|
||
@pytest.mark.asyncio | ||
async 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 = await _power_get('192.168.1.100', None, "0") | ||
assert result is True | ||
|
||
@pytest.mark.asyncio | ||
async 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 = await _power_get('192.168.1.100', None, "0") | ||
assert result is False | ||
|
||
@pytest.mark.asyncio | ||
async 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 = await _power_get('192.168.1.100', None, invalid_index_ignored) | ||
assert result is True | ||
|
||
@pytest.mark.asyncio | ||
async 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): | ||
await _power_set('192.168.1.100', None, "0", True) | ||
mock_device_single_plug.turn_on.assert_called_once() | ||
|
||
@pytest.mark.asyncio | ||
async 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): | ||
await _power_set('192.168.1.100', None, "0", False) | ||
mock_device_single_plug.turn_off.assert_called_once() | ||
|
||
@pytest.mark.asyncio | ||
async 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 = await _power_get('192.168.1.100', None, "0") | ||
assert result is True | ||
|
||
# Test second outlet (off) | ||
result = await _power_get('192.168.1.100', None, "1") | ||
assert result is False | ||
|
||
# Test third outlet (on) | ||
result = await _power_get('192.168.1.100', None, "2") | ||
assert result is True | ||
|
||
@pytest.mark.asyncio | ||
async def test_power_set_strip_valid_socket(self, mock_device_strip, mock_env): | ||
with patch('kasa.Device.connect', return_value=mock_device_strip): | ||
await _power_set('192.168.1.100', None, "0", False) | ||
mock_device_strip.children[0].turn_off.assert_called_once() | ||
|
||
await _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): | ||
with pytest.raises(AssertionError, match="Trying to access non-existant plug socket"): | ||
power_get('192.168.1.100', None, invalid_socket) | ||
|
||
@pytest.mark.asyncio | ||
async 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): | ||
with pytest.raises(AssertionError, match="Trying to access non-existant plug socket"): | ||
await _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): | ||
with 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): | ||
with pytest.raises(AssertionError): | ||
power_get('192.168.1.100', '8080', "0") |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should instead check the python-kasa version instead of relying on the indirect python dependency here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. All tests as written in the description of this PR were re-done.