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 SUPPORTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th

- **EP10**
- Hardware: 1.0 (US) / Firmware: 1.0.2
- Hardware: 1.0 (US) / Firmware: 1.1.1
- **EP25**
- Hardware: 1.0 (US) / Firmware: 1.0.14
- Hardware: 2.6 (US) / Firmware: 1.0.1[^1]
Expand All @@ -32,6 +33,7 @@ Some newer Kasa devices require authentication. These are marked with [^1] in th
- Hardware: 1.0 (US) / Firmware: 1.5.7
- Hardware: 2.1 (US) / Firmware: 1.1.2
- Hardware: 2.1 (US) / Firmware: 1.1.4
- Hardware: 5.0 (US) / Firmware: 1.1.3
- **HS105**
- Hardware: 1.0 (US) / Firmware: 1.5.6
- **HS110**
Expand Down
87 changes: 73 additions & 14 deletions kasa/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,8 @@ def __init__(
self.timeout = timeout
self.discovery_timeout = discovery_timeout
self.seen_hosts: set[str] = set()
self.seen_responses: set[tuple[str, int]] = set()
self.discovered_device_ports: dict[str, int] = {}
self.discover_task: asyncio.Task | None = None
self.callback_tasks: list[asyncio.Task] = []
self.target_discovered: bool = False
Expand Down Expand Up @@ -330,7 +332,7 @@ async def do_discover(self) -> None:

aes_discovery_query = _AesDiscoveryQuery.generate_query()
for _ in range(self.discovery_packets):
if self.target in self.seen_hosts: # Stop sending for discover_single
if self.target_discovered: # Stop sending for discover_single
break
self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore
self.transport.sendto(aes_discovery_query, self.target_2) # type: ignore
Expand All @@ -347,10 +349,11 @@ def datagram_received(
assert _AesDiscoveryQuery.keypair

ip, port = addr
# Prevent multiple entries due multiple broadcasts
if ip in self.seen_hosts:
response_key = (ip, port)
# Prevent duplicate entries from repeated broadcasts on the same port.
if response_key in self.seen_responses:
return
self.seen_hosts.add(ip)
self.seen_responses.add(response_key)

device: Device | None = None

Expand Down Expand Up @@ -379,27 +382,83 @@ def datagram_received(
device = device_func(info, config)
except UnsupportedDeviceError as udex:
_LOGGER.debug("Unsupported device found at %s << %s", ip, udex)
self.unsupported_device_exceptions[ip] = udex
if ip not in self.discovered_devices:
self.unsupported_device_exceptions[ip] = udex
if self.on_unsupported is not None:
self._run_callback_task(self.on_unsupported(udex))
self._handle_discovered_event()
self._handle_discovered_event(ip, port, None)
return
except KasaException as ex:
_LOGGER.debug("[DISCOVERY] Unable to find device type for %s: %s", ip, ex)
self.invalid_device_exceptions[ip] = ex
self._handle_discovered_event()
if ip not in self.discovered_devices:
self.invalid_device_exceptions[ip] = ex
self._handle_discovered_event(ip, port, None)
return

self.discovered_devices[ip] = device
existing = self.discovered_devices.get(ip)
existing_port = self.discovered_device_ports.get(ip)
if existing is None or self._should_replace_discovered_device(
existing, existing_port, device, port
):
self.discovered_devices[ip] = device
self.discovered_device_ports[ip] = port
self.unsupported_device_exceptions.pop(ip, None)
self.invalid_device_exceptions.pop(ip, None)

if self.on_discovered is not None:
if self.on_discovered is not None and self.discovered_devices.get(ip) is device:
self._run_callback_task(self.on_discovered(device))

self._handle_discovered_event()
self._handle_discovered_event(ip, port, self.discovered_devices.get(ip))

@staticmethod
def _is_iot_device(device: Device | None) -> bool:
"""Return true if the discovered device uses the legacy IOT family."""
if device is None:
return False
return device.config.connection_type.device_family.value.startswith("IOT.")

def _device_preference(self, device: Device, port: int | None) -> int:
"""Return preference ordering for discovered devices on the same host."""
if (
port == self.discovery_port
and self._is_iot_device(device)
and device.config.connection_type.encryption_type
is DeviceEncryptionType.Xor
):
return 1
return 0

def _should_replace_discovered_device(
self,
current: Device,
current_port: int | None,
candidate: Device,
candidate_port: int,
) -> bool:
"""Return true if a new discovery response should replace an existing one."""
return self._device_preference(candidate, candidate_port) > (
self._device_preference(current, current_port)
)

def _handle_discovered_event(self) -> None:
"""If target is in seen_hosts cancel discover_task."""
if self.target in self.seen_hosts:
def _handle_discovered_event(
self,
ip: str | None = None,
port: int | None = None,
device: Device | None = None,
) -> None:
"""Cancel single-host discovery once the target has an unambiguous result."""
if ip is None:
ip = self.target
if port is None:
port = self.discovery_port

if self.target != ip:
return

if port == self.discovery_port or (
device is not None and not self._is_iot_device(device)
):
self.seen_hosts.add(ip)
self.target_discovered = True
if self.discover_task:
self.discover_task.cancel()
Expand Down
64 changes: 64 additions & 0 deletions tests/fixtures/iot/EP10(US)_1.0_1.1.1.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"cnCloud": {
"get_info": {
"binded": 1,
"cld_connection": 1,
"err_code": 0,
"fwDlPage": "",
"fwNotifyType": -1,
"illegalType": 0,
"server": "n-devs.tplinkcloud.com",
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,
"fw_list": []
}
},
"schedule": {
"get_next_action": {
"err_code": 0,
"type": -1
},
"get_rules": {
"enable": 0,
"err_code": 0,
"rule_list": [],
"version": 2
}
},
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,
"feature": "TIM",
"hwId": "00000000000000000000000000000000",
"hw_ver": "1.0",
"icon_hash": "",
"latitude_i": 0,
"led_off": 0,
"lnk_on": 1,
"longitude_i": 0,
"mac": "5C:E9:31:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "EP10(US)",
"next_action": {
"type": -1
},
"obd_src": "tplink",
"oemId": "00000000000000000000000000000000",
"on_time": 0,
"relay_state": 0,
"rssi": -43,
"status": "new",
"sw_ver": "1.1.1 Build 250908 Rel.112508",
"updating": 0
}
}
}
64 changes: 64 additions & 0 deletions tests/fixtures/iot/HS103(US)_5.0_1.1.3.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"cnCloud": {
"get_info": {
"binded": 1,
"cld_connection": 1,
"err_code": 0,
"fwDlPage": "",
"fwNotifyType": -1,
"illegalType": 0,
"server": "n-devs.tplinkcloud.com",
"stopConnect": 0,
"tcspInfo": "",
"tcspStatus": 1,
"username": "user@example.com"
},
"get_intl_fw_list": {
"err_code": 0,
"fw_list": []
}
},
"schedule": {
"get_next_action": {
"err_code": 0,
"type": -1
},
"get_rules": {
"enable": 0,
"err_code": 0,
"rule_list": [],
"version": 2
}
},
"system": {
"get_sysinfo": {
"active_mode": "none",
"alias": "#MASKED_NAME#",
"dev_name": "Smart Wi-Fi Plug Mini",
"deviceId": "0000000000000000000000000000000000000000",
"err_code": 0,
"feature": "TIM",
"hwId": "00000000000000000000000000000000",
"hw_ver": "5.0",
"icon_hash": "",
"latitude_i": 0,
"led_off": 0,
"lnk_on": 1,
"longitude_i": 0,
"mac": "1C:61:B4:00:00:00",
"mic_type": "IOT.SMARTPLUGSWITCH",
"model": "HS103(US)",
"next_action": {
"type": -1
},
"obd_src": "tplink",
"oemId": "00000000000000000000000000000000",
"on_time": 0,
"relay_state": 0,
"rssi": -40,
"status": "new",
"sw_ver": "1.1.3 Build 250908 Rel.112508",
"updating": 0
}
}
}
Loading
Loading