Skip to content
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

Release/0.7.0.5 #32

Open
wants to merge 3 commits into
base: patch
Choose a base branch
from
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
16 changes: 15 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
# Changelog

## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-011)
## [0.7.0.5](https://github.com/python-kasa/python-kasa/tree/0.7.0.5) (2024-07-18)

[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.4...0.7.0.5)

A critical bugfix for an issue with some L530 Series devices and a redactor for sensitive info from debug logs.

**Fixed bugs:**

- Only refresh smart LightEffect module daily [\#1064](https://github.com/python-kasa/python-kasa/pull/1064)

**Project maintenance:**

- Redact sensitive info from debug logs [\#1069](https://github.com/python-kasa/python-kasa/pull/1069)

## [0.7.0.4](https://github.com/python-kasa/python-kasa/tree/0.7.0.4) (2024-07-11)

[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.7.0.3...0.7.0.4)

Expand Down
40 changes: 34 additions & 6 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@
import logging
import socket
from collections.abc import Awaitable
from typing import Callable, Dict, Optional, Type, cast
from pprint import pformat as pf
from typing import Any, Callable, Dict, Optional, Type, cast

# When support for cpython older than 3.11 is dropped
# async_timeout can be replaced with asyncio.timeout
Expand All @@ -112,8 +113,10 @@
UnsupportedDeviceError,
)
from kasa.iot.iotdevice import IotDevice
from kasa.iotprotocol import REDACTORS as IOT_REDACTORS
from kasa.json import dumps as json_dumps
from kasa.json import loads as json_loads
from kasa.protocol import mask_mac, redact_data
from kasa.xortransport import XorEncryption

_LOGGER = logging.getLogger(__name__)
Expand All @@ -123,6 +126,12 @@
OnUnsupportedCallable = Callable[[UnsupportedDeviceError], Awaitable[None]]
DeviceDict = Dict[str, Device]

NEW_DISCOVERY_REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"device_id": lambda x: "REDACTED_" + x[9::],
"owner": lambda x: "REDACTED_" + x[9::],
"mac": mask_mac,
}


class _DiscoverProtocol(asyncio.DatagramProtocol):
"""Implementation of the discovery protocol handler.
Expand Down Expand Up @@ -293,6 +302,8 @@ class Discover:
DISCOVERY_PORT_2 = 20002
DISCOVERY_QUERY_2 = binascii.unhexlify("020000010000000000000000463cb5d3")

_redact_data = True

@staticmethod
async def discover(
*,
Expand Down Expand Up @@ -484,7 +495,9 @@ def _get_device_instance_legacy(data: bytes, config: DeviceConfig) -> IotDevice:
f"Unable to read response from device: {config.host}: {ex}"
) from ex

_LOGGER.debug("[DISCOVERY] %s << %s", config.host, info)
if _LOGGER.isEnabledFor(logging.DEBUG):
data = redact_data(info, IOT_REDACTORS) if Discover._redact_data else info
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data))

device_class = cast(Type[IotDevice], Discover._get_device_class(info))
device = device_class(config.host, config=config)
Expand All @@ -504,6 +517,7 @@ def _get_device_instance(
config: DeviceConfig,
) -> Device:
"""Get SmartDevice from the new 20002 response."""
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)
try:
info = json_loads(data[16:])
except Exception as ex:
Expand All @@ -514,9 +528,17 @@ def _get_device_instance(
try:
discovery_result = DiscoveryResult(**info["result"])
except ValidationError as ex:
_LOGGER.debug(
"Unable to parse discovery from device %s: %s", config.host, info
)
if debug_enabled:
data = (
redact_data(info, NEW_DISCOVERY_REDACTORS)
if Discover._redact_data
else info
)
_LOGGER.debug(
"Unable to parse discovery from device %s: %s",
config.host,
pf(data),
)
raise UnsupportedDeviceError(
f"Unable to parse discovery from device: {config.host}: {ex}"
) from ex
Expand Down Expand Up @@ -551,7 +573,13 @@ def _get_device_instance(
discovery_result=discovery_result.get_dict(),
)

_LOGGER.debug("[DISCOVERY] %s << %s", config.host, info)
if debug_enabled:
data = (
redact_data(info, NEW_DISCOVERY_REDACTORS)
if Discover._redact_data
else info
)
_LOGGER.debug("[DISCOVERY] %s << %s", config.host, pf(data))
device = device_class(config.host, protocol=protocol)

di = discovery_result.get_dict()
Expand Down
39 changes: 37 additions & 2 deletions kasa/iotprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import asyncio
import logging
from pprint import pformat as pf
from typing import Any, Callable

from .deviceconfig import DeviceConfig
from .exceptions import (
Expand All @@ -14,11 +16,26 @@
_RetryableError,
)
from .json import dumps as json_dumps
from .protocol import BaseProtocol, BaseTransport
from .protocol import BaseProtocol, BaseTransport, mask_mac, redact_data
from .xortransport import XorEncryption, XorTransport

_LOGGER = logging.getLogger(__name__)

REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0,
"longitude": lambda x: 0,
"latitude_i": lambda x: 0,
"longitude_i": lambda x: 0,
"deviceId": lambda x: "REDACTED_" + x[9::],
"id": lambda x: "REDACTED_" + x[9::],
"alias": lambda x: "#MASKED_NAME#" if x else "",
"mac": mask_mac,
"mic_mac": mask_mac,
"ssid": lambda x: "#MASKED_SSID#" if x else "",
"oemId": lambda x: "REDACTED_" + x[9::],
"username": lambda _: "[email protected]", # cnCloud
}


class IotProtocol(BaseProtocol):
"""Class for the legacy TPLink IOT KASA Protocol."""
Expand All @@ -34,6 +51,7 @@ def __init__(
super().__init__(transport=transport)

self._query_lock = asyncio.Lock()
self._redact_data = True

async def query(self, request: str | dict, retry_count: int = 3) -> dict:
"""Query the device retrying for retry_count on failure."""
Expand Down Expand Up @@ -85,7 +103,24 @@ async def _query(self, request: str, retry_count: int = 3) -> dict:
raise KasaException("Query reached somehow to unreachable")

async def _execute_query(self, request: str, retry_count: int) -> dict:
return await self._transport.send(request)
debug_enabled = _LOGGER.isEnabledFor(logging.DEBUG)

if debug_enabled:
_LOGGER.debug(
"%s >> %s",
self._host,
request,
)
resp = await self._transport.send(request)

if debug_enabled:
data = redact_data(resp, REDACTORS) if self._redact_data else resp
_LOGGER.debug(
"%s << %s",
self._host,
pf(data),
)
return resp

async def close(self) -> None:
"""Close the underlying transport."""
Expand Down
9 changes: 2 additions & 7 deletions kasa/klaptransport.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@
import secrets
import struct
import time
from pprint import pformat as pf
from typing import Any, cast

from cryptography.hazmat.primitives import padding
Expand Down Expand Up @@ -349,19 +348,15 @@ async def send(self, request: str):
+ f"request with seq {seq}"
)
else:
_LOGGER.debug("Query posted " + msg)
_LOGGER.debug("Device %s query posted %s", self._host, msg)

# Check for mypy
if self._encryption_session is not None:
decrypted_response = self._encryption_session.decrypt(response_data)

json_payload = json_loads(decrypted_response)

_LOGGER.debug(
"%s << %s",
self._host,
_LOGGER.isEnabledFor(logging.DEBUG) and pf(json_payload),
)
_LOGGER.debug("Device %s query response received", self._host)

return json_payload

Expand Down
41 changes: 41 additions & 0 deletions kasa/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import logging
import struct
from abc import ABC, abstractmethod
from typing import Any, Callable, TypeVar, cast

# When support for cpython older than 3.11 is dropped
# async_timeout can be replaced with asyncio.timeout
Expand All @@ -28,6 +29,46 @@
_NO_RETRY_ERRORS = {errno.EHOSTDOWN, errno.EHOSTUNREACH, errno.ECONNREFUSED}
_UNSIGNED_INT_NETWORK_ORDER = struct.Struct(">I")

_T = TypeVar("_T")


def redact_data(data: _T, redactors: dict[str, Callable[[Any], Any] | None]) -> _T:
"""Redact sensitive data for logging."""
if not isinstance(data, (dict, list)):
return data

if isinstance(data, list):
return cast(_T, [redact_data(val, redactors) for val in data])

redacted = {**data}

for key, value in redacted.items():
if value is None:
continue
if isinstance(value, str) and not value:
continue
if key in redactors:
if redactor := redactors[key]:
try:
redacted[key] = redactor(value)
except: # noqa: E722
redacted[key] = "**REDACTEX**"
else:
redacted[key] = "**REDACTED**"
elif isinstance(value, dict):
redacted[key] = redact_data(value, redactors)
elif isinstance(value, list):
redacted[key] = [redact_data(item, redactors) for item in value]

return cast(_T, redacted)


def mask_mac(mac: str) -> str:
"""Return mac address with last two octects blanked."""
delim = ":" if ":" in mac else "-"
rest = delim.join(format(s, "02x") for s in bytes.fromhex("000000"))
return f"{mac[:8]}{delim}{rest}"


def md5(payload: bytes) -> bytes:
"""Return the MD5 hash of the payload."""
Expand Down
3 changes: 2 additions & 1 deletion kasa/smart/modules/lighteffect.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class LightEffect(SmartModule, SmartLightEffect):

REQUIRED_COMPONENT = "light_effect"
QUERY_GETTER_NAME = "get_dynamic_light_effect_rules"
MINIMUM_UPDATE_INTERVAL_SECS = 60
MINIMUM_UPDATE_INTERVAL_SECS = 60 * 60 * 24
AVAILABLE_BULB_EFFECTS = {
"L1": "Party",
"L2": "Relax",
Expand Down Expand Up @@ -74,6 +74,7 @@ def effect(self) -> str:
"""Return effect name."""
return self._effect

@allow_update_after
async def set_effect(
self,
effect: str,
Expand Down
8 changes: 3 additions & 5 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,9 @@ async def update(self, update_children: bool = False):
if not self._features:
await self._initialize_features()

_LOGGER.debug(
"Update completed %s: %s",
self.host,
self._last_update if first_update else resp,
)
if _LOGGER.isEnabledFor(logging.DEBUG):
updated = self._last_update if first_update else resp
_LOGGER.debug("Update completed %s: %s", self.host, list(updated.keys()))

def _handle_module_post_update_hook(self, module: SmartModule) -> bool:
try:
Expand Down
32 changes: 29 additions & 3 deletions kasa/smartprotocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import time
import uuid
from pprint import pformat as pf
from typing import Any
from typing import Any, Callable

from .exceptions import (
SMART_AUTHENTICATION_ERRORS,
Expand All @@ -26,10 +26,31 @@
_RetryableError,
)
from .json import dumps as json_dumps
from .protocol import BaseProtocol, BaseTransport, md5
from .protocol import BaseProtocol, BaseTransport, mask_mac, md5, redact_data

_LOGGER = logging.getLogger(__name__)

REDACTORS: dict[str, Callable[[Any], Any] | None] = {
"latitude": lambda x: 0,
"longitude": lambda x: 0,
"la": lambda x: 0, # lat on ks240
"lo": lambda x: 0, # lon on ks240
"device_id": lambda x: "REDACTED_" + x[9::],
"parent_device_id": lambda x: "REDACTED_" + x[9::], # Hub attached children
"original_device_id": lambda x: "REDACTED_" + x[9::], # Strip children
"nickname": lambda x: "I01BU0tFRF9OQU1FIw==" if x else "",
"mac": mask_mac,
"ssid": lambda x: "I01BU0tFRF9TU0lEIw=" if x else "",
"bssid": lambda _: "000000000000",
"oem_id": lambda x: "REDACTED_" + x[9::],
"setup_code": None, # matter
"setup_payload": None, # matter
"mfi_setup_code": None, # mfi_ for homekit
"mfi_setup_id": None,
"mfi_token_token": None,
"mfi_token_uuid": None,
}


class SmartProtocol(BaseProtocol):
"""Class for the new TPLink SMART protocol."""
Expand All @@ -50,6 +71,7 @@ def __init__(
self._multi_request_batch_size = (
self._transport._config.batch_size or self.DEFAULT_MULTI_REQUEST_BATCH_SIZE
)
self._redact_data = True

def get_smart_request(self, method, params=None) -> str:
"""Get a request message as a string."""
Expand Down Expand Up @@ -167,11 +189,15 @@ async def _execute_multiple_query(self, requests: dict, retry_count: int) -> dic
)
response_step = await self._transport.send(smart_request)
if debug_enabled:
if self._redact_data:
data = redact_data(response_step, REDACTORS)
else:
data = response_step
_LOGGER.debug(
"%s %s << %s",
self._host,
batch_name,
pf(response_step),
pf(data),
)
try:
self._handle_response_error_code(response_step, batch_name)
Expand Down
Loading
Loading