Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions kasa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DeviceError,
KasaException,
TimeoutError,
UnsupportedAuthenticationError,
UnsupportedDeviceError,
)
from kasa.feature import Feature
Expand Down Expand Up @@ -68,6 +69,7 @@
"KasaException",
"AuthenticationError",
"DeviceError",
"UnsupportedAuthenticationError",
"UnsupportedDeviceError",
"TimeoutError",
"Credentials",
Expand Down
21 changes: 16 additions & 5 deletions kasa/cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from kasa.protocols.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.protocols.protocol import redact_data

from ..exceptions import UnsupportedAuthenticationError
from ..json import dumps as json_dumps
from .common import echo, error

Expand Down Expand Up @@ -70,14 +71,20 @@ async def detail(ctx: click.Context) -> DeviceDict:
async def print_unsupported(unsupported_exception: UnsupportedDeviceError) -> None:
unsupported.append(unsupported_exception)
async with sem:
echo("== Unsupported device ==")
echo(f"\t{unsupported_exception}")
echo()

if unsupported_exception.discovery_result:
echo("== Unsupported device ==")
_echo_discovery_info(unsupported_exception.discovery_result)
echo()
else:
echo("== Unsupported device ==")
echo(f"\t{unsupported_exception}")
echo()
if isinstance(unsupported_exception, UnsupportedAuthenticationError):
obd_src = unsupported_exception.discovery_result.get("obd_src")
echo(
f"\t[red bold]Provisioned using unsupported"
f" '{obd_src}'.[/red bold]"
)
echo("\tTo fix, reset and provision manually.")

from .device import state

Expand All @@ -87,6 +94,8 @@ async def print_discovered(dev: Device) -> None:
async with sem:
try:
await dev.update()
except UnsupportedAuthenticationError as ex:
await print_unsupported(ex)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If print_discovered already holds the sem, calling print_unsupported will try to grab the same sem and cause a deadlock.

except AuthenticationError:
if TYPE_CHECKING:
assert dev._discovery_info
Expand Down Expand Up @@ -159,6 +168,8 @@ async def print_discovered(dev: Device):
async with sem:
try:
await dev.update()
except UnsupportedAuthenticationError as ex:
await print_unsupported(ex)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above, right?

except AuthenticationError:
echo(f"{infostr} - Authentication failed")
except TimeoutError:
Expand Down
13 changes: 12 additions & 1 deletion kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
from kasa.exceptions import (
KasaException,
TimeoutError,
UnsupportedAuthenticationError,
UnsupportedDeviceError,
)
from kasa.iot.iotdevice import IotDevice, _extract_sys_info
Expand Down Expand Up @@ -284,6 +285,16 @@ def _run_callback_task(self, coro: Coroutine) -> None:
task: asyncio.Task = asyncio.create_task(coro)
self.callback_tasks.append(task)

async def _on_discovered_wrapper(self, device: Device) -> None:
"""Wrap on_discovered to handle UnsupportedAuthenticationError."""
assert self.on_discovered is not None # noqa: S101
try:
await self.on_discovered(device)
except UnsupportedAuthenticationError as ex:
self.unsupported_device_exceptions[device.host] = ex
if self.on_unsupported is not None:
await self.on_unsupported(ex)

async def wait_for_discovery_to_complete(self) -> None:
"""Wait for the discovery task to complete."""
# Give some time for connection_made event to be received
Expand Down Expand Up @@ -393,7 +404,7 @@ def datagram_received(
self.discovered_devices[ip] = device

if self.on_discovered is not None:
self._run_callback_task(self.on_discovered(device))
self._run_callback_task(self._on_discovered_wrapper(device))

self._handle_discovered_event()

Expand Down
12 changes: 12 additions & 0 deletions kasa/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,3 +200,15 @@ def from_int(value: int) -> SmartErrorCode:
SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR,
SmartErrorCode.HOMEKIT_LOGIN_FAIL,
]


#: Onboarding sources that are known to work
SUPPORTED_OBD_SRCS = {"tplink", "matter", "apple"}


class UnsupportedAuthenticationError(UnsupportedDeviceError, AuthenticationError):
"""Raised when authentication fails with unsupported provisioning method.

This can be used to display a more helpful message in when we know it is
not necessarily an issue with the inputted credentials.
"""
23 changes: 22 additions & 1 deletion kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
from ..device import Device, DeviceInfo, WifiNetwork
from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..exceptions import (
SUPPORTED_OBD_SRCS,
AuthenticationError,
DeviceError,
KasaException,
SmartErrorCode,
UnsupportedAuthenticationError,
)
from ..feature import Feature
from ..module import Module
from ..modulemapping import ModuleMapping, ModuleName
Expand Down Expand Up @@ -263,6 +270,20 @@ async def update(self, update_children: bool = True) -> None:
if self.credentials is None and self.credentials_hash is None:
raise AuthenticationError("Tapo plug requires authentication.")

try:
await self._update(update_children)
except AuthenticationError as ex:
obd_src = self._discovery_info and self._discovery_info.get("obd_src")
Comment on lines +273 to +276

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done here as we do not know during the discovery time, pre-update, if the credentials are wrong or if we do not just support the authentication.

The known good sources are based on our fixtures, tss and amazon's obd are not supported, but maybe there exists also others, so we do not want to raise during the discovery to avoid raising unnecessarily.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be fine for now and when we go through the discovery process completely, we can handle authentication and unsupported errors separately.

if obd_src and obd_src not in SUPPORTED_OBD_SRCS:
raise UnsupportedAuthenticationError(
f"Device at {self.host} uses unsupported onboarding '{obd_src}'.",
discovery_result=self._discovery_info,
host=self.host,
) from ex
raise

async def _update(self, update_children: bool = True) -> None:
"""Update implementation."""
first_update = self._last_update_time is None
now = time.monotonic()
self._last_update_time = now
Expand Down
52 changes: 52 additions & 0 deletions tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,58 @@ async def test_device_update_from_new_discovery_info(discovery_mock):
assert device.modules


async def test_unsupported_authentication_exception_for_tss(mocker):
"""Test that unknown obd_src raises UnsupportedAuthenticationError after auth fails."""
from kasa.discover import (
Discover,
DiscoveryResult,
EncryptionScheme,
_DiscoverProtocol,
)
from kasa.exceptions import UnsupportedAuthenticationError

# tss devices have valid connection params but unknown credentials
dr = DiscoveryResult(
device_type="SMART.TAPOPLUG",
device_model="P316M(US)",
device_id="SomeID",
ip="127.0.0.2",
mac="00:11:22:33:44:55",
obd_src="tss",
mgt_encrypt_schm=EncryptionScheme(
encrypt_type="KLAP", lv=2, is_support_https=False
),
)

assert Discover._get_connection_parameters(dr) is not None

unsupported_calls = []

async def on_discovered(dev):
raise UnsupportedAuthenticationError(
"Device at 127.0.0.2 uses unsupported onboarding 'tss'.",
discovery_result=dr.to_dict(),
host="127.0.0.2",
)

async def on_unsupported(ex):
unsupported_calls.append(ex)

protocol = _DiscoverProtocol(
>
on_discovered=on_discovered,
on_unsupported=on_unsupported,
)
device = mocker.MagicMock()
device.host = "127.0.0.2"

await protocol._on_discovered_wrapper(device)

assert len(unsupported_calls) == 1
assert isinstance(unsupported_calls[0], UnsupportedAuthenticationError)
assert "unsupported onboarding 'tss'" in str(unsupported_calls[0])


async def test_discover_single_http_client(discovery_mock, mocker):
"""Make sure that discover_single returns an initialized SmartDevice instance."""
host = "127.0.0.1"
Expand Down
Loading