Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
3f82910
Initial TPAP Implementation
ZeliardM Oct 18, 2025
c7a3405
Clean up and coverage testing
ZeliardM Oct 19, 2025
3b15fdd
Try to fix CodeQL Security Warnings
ZeliardM Oct 19, 2025
4afa9eb
Change request headers and ssl context
ZeliardM Oct 19, 2025
65fe12b
Correct sessionId
ZeliardM Oct 21, 2025
6a1c037
Update ciphers
ZeliardM Oct 21, 2025
802b8a1
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Nov 4, 2025
be03dff
tpaptransport re-write and test coverage
ZeliardM Nov 5, 2025
4582ec1
Merge branch 'feature/tpap' of https://github.com/ZeliardM/python-kas…
ZeliardM Nov 5, 2025
5baddb0
TPAP Certificate handling changes and logging
ZeliardM Dec 8, 2025
43b779d
Fix NOC Authentication Flow
ZeliardM Dec 8, 2025
835c411
Fix test_tpaptransport.py tests
ZeliardM Dec 8, 2025
a2f0df6
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Dec 8, 2025
d6eeba9
Changes to TSLP Wrapper
ZeliardM Dec 8, 2025
f6837d7
TSLP Decode for Authentication
ZeliardM Dec 9, 2025
86a4ed3
Debug logging for tpaptransport.py
ZeliardM Dec 10, 2025
646ca65
Correct debug logging statements
ZeliardM Dec 10, 2025
8d19e6a
Revert to json for login
ZeliardM Dec 10, 2025
be146b2
Update test coverage
ZeliardM Dec 10, 2025
f4d81f2
Additional logging
ZeliardM Dec 10, 2025
99e0288
Raw response logging instead
ZeliardM Dec 10, 2025
78016e4
Correct noc session parameters
ZeliardM Dec 10, 2025
9005319
Update tpaptransport.py NOC encryption
ZeliardM Dec 10, 2025
21cda17
Test tpap parameter names
ZeliardM Dec 10, 2025
6bdad67
Return key values to correct format and add logging
ZeliardM Dec 11, 2025
ade20d7
Use base64 instead of hex for user_pk
ZeliardM Dec 11, 2025
f5d0da5
Updates and fixes for SPAKE2+ handling
ZeliardM Dec 12, 2025
79bcf3d
Update test coverage
ZeliardM Dec 12, 2025
022775a
Update test coverage
ZeliardM Dec 12, 2025
5f9fc5e
Cleaned up logging and test coverages
ZeliardM Dec 12, 2025
7e5fae8
Add http for TPAP
ZeliardM Dec 13, 2025
3a8eddd
Add correct http port handling for tpap
ZeliardM Dec 13, 2025
230b822
Update spake2+ handling
ZeliardM Dec 13, 2025
d15cbf9
Initial plan
Copilot Dec 13, 2025
c297f54
Add TPAP debug logs documentation file
Copilot Dec 13, 2025
9e1da3d
Add comprehensive debug logging to Spake2pAuthContext and remove mark…
Copilot Dec 13, 2025
a876e4f
Add comprehensive debug logging coverage for all Spake2pAuthContext s…
Copilot Dec 13, 2025
f331495
Merge pull request #1 from ZeliardM/copilot/add-tpap-debug-logs
ZeliardM Dec 13, 2025
31e337f
Initial plan
Copilot Dec 14, 2025
0f5a468
Fix line length violations in tpaptransport.py
Copilot Dec 14, 2025
c1f92ea
Fix final line length violation in tpaptransport.py
Copilot Dec 14, 2025
3a72145
Add coverage.xml to .gitignore and remove from tracking
Copilot Dec 14, 2025
547ead9
Merge pull request #2 from ZeliardM/copilot/check-ci-workflows
ZeliardM Dec 14, 2025
d51788b
Fix initial cipher_suites
ZeliardM Dec 14, 2025
7db0f2c
Update tpap test coverage
ZeliardM Dec 14, 2025
cf5abbd
Update test logging
ZeliardM Dec 14, 2025
6c27973
Fix encoding error
ZeliardM Dec 14, 2025
5ea34bb
Extra logging
ZeliardM Dec 15, 2025
faad4c7
Update hkdf_expand
ZeliardM Dec 15, 2025
f90f4a0
Additional logging
ZeliardM Dec 15, 2025
e8681b5
Fix hkdf expand
ZeliardM Dec 15, 2025
3b1b699
Update logging
ZeliardM Dec 15, 2025
67cc57f
Fix logging
ZeliardM Dec 15, 2025
f55868c
Fix confirmation keys length
ZeliardM Dec 16, 2025
ed8e57f
Starting cleanup from testing
ZeliardM Dec 16, 2025
b8d0387
Update test coverage
ZeliardM Dec 16, 2025
34c89f3
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Jan 27, 2026
7c6bc64
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Feb 22, 2026
a88995e
Merge branch 'master' into feature/tpap
ZeliardM Feb 27, 2026
a170141
Update for additional SPAKE2+ Curves
ZeliardM Feb 27, 2026
60c7a00
Possible CodeQL Scanning Fix
ZeliardM Feb 27, 2026
987097c
Remove inline code security scanning blocks
ZeliardM Feb 27, 2026
18736c2
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Mar 4, 2026
84e2aad
Complete re-write of TPAP Implementation
ZeliardM Mar 20, 2026
894db97
Revert httpclient.py handling and cleanup tpaptransport.py
ZeliardM Mar 21, 2026
0e9f22e
Revert test_discovery.py changes
ZeliardM Mar 21, 2026
341103c
Merge branch 'python-kasa:master' into feature/tpap
ZeliardM Apr 5, 2026
2640c28
Rework of tpaptransport.py and test coverages
ZeliardM Apr 9, 2026
1a5970d
Add Tapo H110 IR Air Conditioner support
Andrei965 Jun 30, 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ __pycache__/

# Coverage
.coverage
coverage.xml

# Tox
.tox
Expand Down
16 changes: 15 additions & 1 deletion kasa/device_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
KlapTransportV2,
LinkieTransportV2,
SslTransport,
TpapTransport,
XorTransport,
)
from .transports.sslaestransport import SslAesTransport
Expand Down Expand Up @@ -196,6 +197,16 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
protocol_name = ctype.device_family.value.split(".")[0]
_LOGGER.debug("Finding protocol for %s", ctype.device_family)

if ctype.encryption_type is DeviceEncryptionType.Tpap and (
ctype.device_family
in {
DeviceFamily.SmartIpCamera,
DeviceFamily.SmartTapoDoorbell,
}
or (ctype.device_family is DeviceFamily.SmartTapoHub and ctype.https)
):
return SmartCamProtocol(transport=TpapTransport(config=config))

if ctype.device_family in {
DeviceFamily.SmartIpCamera,
DeviceFamily.SmartTapoDoorbell,
Expand Down Expand Up @@ -233,8 +244,11 @@ def get_protocol(config: DeviceConfig, *, strict: bool = False) -> BaseProtocol
"SMART.KLAP": (SmartProtocol, KlapTransportV2),
"SMART.KLAP.HTTPS": (SmartProtocol, KlapTransportV2),
# H200 is device family SMART.TAPOHUB and uses SmartCamProtocol so use
# https to distuingish from SmartProtocol devices
# https to distinguish from SmartProtocol devices
"SMART.AES.HTTPS": (SmartCamProtocol, SslAesTransport),
# TPAP devices (SMART.* with encrypt_type TPAP and TPAP/HTTPS).
"SMART.TPAP": (SmartProtocol, TpapTransport),
"SMART.TPAP.HTTPS": (SmartProtocol, TpapTransport),
}
if not (prot_tran_cls := supported_device_protocols.get(protocol_transport_key)):
return None
Expand Down
1 change: 1 addition & 0 deletions kasa/device_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class DeviceType(Enum):
Hub = "hub"
Fan = "fan"
Thermostat = "thermostat"
Climate = "climate"
Vacuum = "vacuum"
Chime = "chime"
Doorbell = "doorbell"
Expand Down
1 change: 1 addition & 0 deletions kasa/deviceconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class DeviceEncryptionType(Enum):
Klap = "KLAP"
Aes = "AES"
Xor = "XOR"
Tpap = "TPAP"


class DeviceFamily(Enum):
Expand Down
3 changes: 2 additions & 1 deletion kasa/smart/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

from .smartchilddevice import SmartChildDevice
from .smartdevice import SmartDevice
from .smartirac import SmartIrAC

__all__ = ["SmartDevice", "SmartChildDevice"]
__all__ = ["SmartDevice", "SmartChildDevice", "SmartIrAC"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As this is a child device, there should be no need to export this.

9 changes: 9 additions & 0 deletions kasa/smart/smartdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,15 @@ async def _initialize_children(self) -> None:
async def _try_create_child(
self, info: dict, child_components: dict
) -> SmartDevice | None:
if info.get("category") == "ir.remote" and info.get("model") == "AC":

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As this is a child device, please move the initialization to happen inside SmartChildDevice.create().

from .smartirac import SmartIrAC

return await SmartIrAC.create(
parent=self,
child_info=info,
child_components_raw=child_components,
)

from .smartchilddevice import SmartChildDevice

return await SmartChildDevice.create(
Expand Down
128 changes: 128 additions & 0 deletions kasa/smart/smartirac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"""Smart IR AC child device implementation."""

from __future__ import annotations

import logging
from typing import Any

from ..device_type import DeviceType
from .smartchilddevice import SmartChildDevice

_LOGGER = logging.getLogger(__name__)


class SmartIrAC(SmartChildDevice):
"""Presentation of a child IR Air Conditioner device."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
self._ac_state: dict[str, int] = {}
super().__init__(*args, **kwargs)

@property
def device_type(self) -> DeviceType:
"""Return the device type."""
try:
return DeviceType.Climate
except AttributeError:
return DeviceType.Thermostat
Comment on lines +22 to +27

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When can this happen?


def _update_internal_state(self, info: dict[str, Any]) -> None:
"""Update the internal info state."""
super()._update_internal_state(info)

ac_status = info.get("ac_status")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We want to have a less fragile parsing of the status. I cannot off the bat recommend how it should look like (could you create a PR to add a fixture?), but we want to make it more robust to avoid breaking if the device responses change suddenly.

If this is a regular smart device, maybe it is only necessary to implement a module to handle this case? Let me know what do you think & if you need any help with deciphering how to bolt modules in!

if ac_status:
parts = ac_status.split("_")
self._ac_state = {}
for part in parts:
if len(part) >= 2:
key = part[0]
try:
value = int(part[1:])
self._ac_state[key] = value
except ValueError:
pass

@property
def is_on(self) -> bool:
"""Return true if the device is on."""
return bool(self._ac_state.get("P", 0))

@property
def target_temperature(self) -> int | None:
"""Return the target temperature."""
return self._ac_state.get("T")

@property
def hvac_mode(self) -> int | None:
"""Return the hvac mode."""
return self._ac_state.get("M")

@property
def fan_mode(self) -> int | None:
"""Return the fan mode."""
return self._ac_state.get("S")

@property
def swing_mode(self) -> int | None:
"""Return the swing mode."""
return self._ac_state.get("D")

async def _send_ir_cmd(self, **kwargs) -> dict:
"""Send IR command to the AC."""
payload = {
"power": bool(self._ac_state.get("P", 0)),
"on": bool(self._ac_state.get("P", 0)),
"mode": self._ac_state.get("M", 1),
"temp": self._ac_state.get("T", 24),
"wind_speed": self._ac_state.get("S", 1),
"wind_direct": self._ac_state.get("D", 0),
}

for key, value in kwargs.items():
payload[key] = value
if key == "power":
payload["on"] = value

request = {
"multipleRequest": {
"requests": [{"method": "sendIrCmdByStatus", "params": payload}]
}
}

if "power" in payload:
self._ac_state["P"] = 1 if payload["power"] else 0
if "mode" in payload:
self._ac_state["M"] = payload["mode"]
if "temp" in payload:
self._ac_state["T"] = payload["temp"]
if "wind_speed" in payload:
self._ac_state["S"] = payload["wind_speed"]
if "wind_direct" in payload:
self._ac_state["D"] = payload["wind_direct"]

return await self.protocol.query(request)

async def set_target_temperature(self, temp: int) -> dict:
"""Set the target temperature."""
return await self._send_ir_cmd(temp=temp)

async def set_hvac_mode(self, mode: int) -> dict:
"""Set the hvac mode."""
return await self._send_ir_cmd(mode=mode)

async def set_fan_mode(self, speed: int) -> dict:
"""Set the fan mode."""
return await self._send_ir_cmd(wind_speed=speed)

async def set_swing_mode(self, swing: int) -> dict:
"""Set the swing mode."""
return await self._send_ir_cmd(wind_direct=swing)

async def turn_on(self, **kwargs: Any) -> dict:
"""Turn on the AC."""
return await self._send_ir_cmd(power=True)

async def turn_off(self, **kwargs: Any) -> dict:
"""Turn off the AC."""
return await self._send_ir_cmd(power=False)
10 changes: 6 additions & 4 deletions kasa/transports/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@
from .linkietransport import LinkieTransportV2
from .sslaestransport import SslAesTransport
from .ssltransport import SslTransport
from .tpaptransport import TpapTransport
from .xortransport import XorEncryption, XorTransport

__all__ = [
"AesTransport",
"AesEncyptionSession",
"SslTransport",
"SslAesTransport",
"AesTransport",
"BaseTransport",
"KlapTransport",
"KlapTransportV2",
"LinkieTransportV2",
"XorTransport",
"SslAesTransport",
"SslTransport",
"TpapTransport",
"XorEncryption",
"XorTransport",
]
Loading
Loading