Skip to content

Commit 530a7b1

Browse files
authored
Add support for polling when passive scanning is used (#161)
1 parent 525984c commit 530a7b1

File tree

13 files changed

+150
-24
lines changed

13 files changed

+150
-24
lines changed

switchbot/adv_parser.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ def parse_advertisement_data(
177177
if not data:
178178
return None
179179

180-
return SwitchBotAdvertisement(device.address, data, device, advertisement_data.rssi)
180+
return SwitchBotAdvertisement(
181+
device.address, data, device, advertisement_data.rssi, bool(_service_data)
182+
)
181183

182184

183185
@lru_cache(maxsize=128)

switchbot/adv_parsers/curtain.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ def process_wocurtain(
2525
"position": (100 - _position) if reverse else _position,
2626
"lightLevel": _light_level,
2727
"deviceChain": _device_chain,
28-
}
28+
}

switchbot/adv_parsers/meter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ def process_wosensorth(data: bytes | None, mfr_data: bytes | None) -> dict[str,
2525
_temp_f = (_temp_f * 10) / 10
2626

2727
_wosensorth_data = {
28+
# Data should be flat, but we keep the original structure for now
2829
"temp": {"c": _temp_c, "f": _temp_f},
30+
"temperature": _temp_c,
2931
"fahrenheit": bool(temp_data[2] & 0b10000000),
3032
"humidity": temp_data[2] & 0b01111111,
3133
"battery": battery,

switchbot/devices/base_light.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
_LOGGER = logging.getLogger(__name__)
1010
import asyncio
11+
import time
1112

1213
from ..models import SwitchBotAdvertisement
1314

@@ -81,6 +82,14 @@ async def set_color_temp(self, brightness: int, color_temp: int) -> bool:
8182
async def set_rgb(self, brightness: int, r: int, g: int, b: int) -> bool:
8283
"""Set rgb."""
8384

85+
def poll_needed(self, last_poll_time: float | None) -> bool:
86+
"""Return if poll is needed."""
87+
return False
88+
89+
async def update(self) -> None:
90+
"""Update device data."""
91+
self._last_full_update = time.monotonic()
92+
8493

8594
class SwitchbotSequenceBaseLight(SwitchbotBaseLight):
8695
"""Representation of a Switchbot light."""

switchbot/devices/bot.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
DEVICE_SET_EXTENDED_KEY,
99
DEVICE_SET_MODE_KEY,
1010
SwitchbotDeviceOverrideStateDuringConnection,
11+
update_after_operation,
1112
)
1213

1314
_LOGGER = logging.getLogger(__name__)
@@ -30,10 +31,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
3031
super().__init__(*args, **kwargs)
3132
self._inverse: bool = kwargs.pop("inverse_mode", False)
3233

33-
async def update(self, interface: int | None = None) -> None:
34-
"""Update mode, battery percent and state of device."""
35-
await self.get_device_data(retry=self._retry_count, interface=interface)
36-
34+
@update_after_operation
3735
async def turn_on(self) -> bool:
3836
"""Turn device on."""
3937
result = await self._send_command(ON_KEY)
@@ -48,6 +46,7 @@ async def turn_on(self) -> bool:
4846
self._fire_callbacks()
4947
return ret
5048

49+
@update_after_operation
5150
async def turn_off(self) -> bool:
5251
"""Turn device off."""
5352
result = await self._send_command(OFF_KEY)
@@ -62,21 +61,25 @@ async def turn_off(self) -> bool:
6261
self._fire_callbacks()
6362
return ret
6463

64+
@update_after_operation
6565
async def hand_up(self) -> bool:
6666
"""Raise device arm."""
6767
result = await self._send_command(UP_KEY)
6868
return self._check_command_result(result, 0, {1, 5})
6969

70+
@update_after_operation
7071
async def hand_down(self) -> bool:
7172
"""Lower device arm."""
7273
result = await self._send_command(DOWN_KEY)
7374
return self._check_command_result(result, 0, {1, 5})
7475

76+
@update_after_operation
7577
async def press(self) -> bool:
7678
"""Press command to device."""
7779
result = await self._send_command(PRESS_KEY)
7880
return self._check_command_result(result, 0, {1, 5})
7981

82+
@update_after_operation
8083
async def set_switch_mode(
8184
self, switch_mode: bool = False, strength: int = 100, inverse: bool = False
8285
) -> bool:
@@ -86,6 +89,7 @@ async def set_switch_mode(
8689
result = await self._send_command(DEVICE_SET_MODE_KEY + strength_key + mode_key)
8790
return self._check_command_result(result, 0, {1})
8891

92+
@update_after_operation
8993
async def set_long_press(self, duration: int = 0) -> bool:
9094
"""Set bot long press duration."""
9195
duration_key = f"{duration:0{2}x}" # to hex with padding to double digit

switchbot/devices/bulb.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ async def update(self) -> None:
3434
"""Update state of device."""
3535
result = await self._send_command(BULB_REQUEST)
3636
self._update_state(result)
37+
await super().update()
3738

3839
async def turn_on(self) -> bool:
3940
"""Turn device on."""

switchbot/devices/ceiling_light.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,6 @@ def color_modes(self) -> set[ColorMode]:
2727
"""Return the supported color modes."""
2828
return {ColorMode.COLOR_TEMP}
2929

30-
async def update(self) -> None:
31-
"""Update state of device."""
32-
3330
async def turn_on(self) -> bool:
3431
"""Turn device on."""
3532
result = await self._send_command(CEILING_LIGHT_ON_KEY)

switchbot/devices/curtain.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import logging
55
from typing import Any
66

7-
from .device import REQ_HEADER, SwitchbotDevice
7+
from .device import REQ_HEADER, SwitchbotDevice, update_after_operation
88

99
# Curtain keys
1010
CURTAIN_COMMAND = "4501"
@@ -62,18 +62,22 @@ async def _send_multiple_commands(self, keys: list[str]) -> bool:
6262
final_result |= self._check_command_result(result, 0, {1})
6363
return final_result
6464

65+
@update_after_operation
6566
async def open(self) -> bool:
6667
"""Send open command."""
6768
return await self._send_multiple_commands(OPEN_KEYS)
6869

70+
@update_after_operation
6971
async def close(self) -> bool:
7072
"""Send close command."""
7173
return await self._send_multiple_commands(CLOSE_KEYS)
7274

75+
@update_after_operation
7376
async def stop(self) -> bool:
7477
"""Send stop command to device."""
7578
return await self._send_multiple_commands(STOP_KEYS)
7679

80+
@update_after_operation
7781
async def set_position(self, position: int) -> bool:
7882
"""Send position command (0-100) to device."""
7983
position = (100 - position) if self._reverse else position
@@ -82,10 +86,6 @@ async def set_position(self, position: int) -> bool:
8286
[key + hex_position for key in POSITION_KEYS]
8387
)
8488

85-
async def update(self, interface: int | None = None) -> None:
86-
"""Update position, battery percent and light level of device."""
87-
await self.get_device_data(retry=self._retry_count, interface=interface)
88-
8989
def get_position(self) -> Any:
9090
"""Return cached position (0-100) of Curtain."""
9191
# To get actual position call update() first.
@@ -108,6 +108,7 @@ async def get_basic_info(self) -> dict[str, Any] | None:
108108
"light": bool(_data[4] & 0b00100000),
109109
"fault": bool(_data[4] & 0b00001000),
110110
"solarPanel": bool(_data[5] & 0b00001000),
111+
"calibration": bool(_data[5] & 0b00000100),
111112
"calibrated": bool(_data[5] & 0b00000100),
112113
"inMotion": bool(_data[5] & 0b01000011),
113114
"position": (100 - _position) if self._reverse else _position,

switchbot/devices/device.py

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
import asyncio
55
import binascii
66
import logging
7+
import time
8+
from dataclasses import replace
79
from enum import Enum
8-
from typing import Any, Callable
10+
from typing import Any, Callable, TypeVar, cast
911
from uuid import UUID
1012

1113
import async_timeout
@@ -53,6 +55,13 @@ class ColorMode(Enum):
5355
EFFECT = 3
5456

5557

58+
# If the scanner is in passive mode, we
59+
# need to poll the device to get the
60+
# battery and a few rarely updating
61+
# values.
62+
PASSIVE_POLL_INTERVAL = 60 * 60 * 24
63+
64+
5665
class CharacteristicMissingError(Exception):
5766
"""Raised when a characteristic is missing."""
5867

@@ -76,6 +85,32 @@ def _sb_uuid(comms_type: str = "service") -> UUID | str:
7685
WRITE_CHAR_UUID = _sb_uuid(comms_type="tx")
7786

7887

88+
WrapFuncType = TypeVar("WrapFuncType", bound=Callable[..., Any])
89+
90+
91+
def update_after_operation(func: WrapFuncType) -> WrapFuncType:
92+
"""Define a wrapper to update after an operation."""
93+
94+
async def _async_update_after_operation_wrap(
95+
self: SwitchbotBaseDevice, *args: Any, **kwargs: Any
96+
) -> None:
97+
ret = await func(self, *args, **kwargs)
98+
await self.update()
99+
self._fire_callbacks()
100+
return ret
101+
102+
return cast(WrapFuncType, _async_update_after_operation_wrap)
103+
104+
105+
def _merge_data(old_data: dict[str, Any], new_data: dict[str, Any]) -> dict[str, Any]:
106+
"""Merge data but only add None keys if they are missing."""
107+
merged = old_data.copy()
108+
for key, value in new_data.items():
109+
if value is not None or key not in old_data:
110+
merged[key] = value
111+
return merged
112+
113+
79114
class SwitchbotBaseDevice:
80115
"""Base Representation of a Switchbot Device."""
81116

@@ -109,6 +144,7 @@ def __init__(
109144
self.loop = asyncio.get_event_loop()
110145
self._callbacks: list[Callable[[], None]] = []
111146
self._notify_future: asyncio.Future[bytearray] | None = None
147+
self._last_full_update: float = -PASSIVE_POLL_INTERVAL
112148

113149
def advertisement_changed(self, advertisement: SwitchBotAdvertisement) -> bool:
114150
"""Check if the advertisement has changed."""
@@ -190,6 +226,18 @@ def name(self) -> str:
190226
"""Return device name."""
191227
return f"{self._device.name} ({self._device.address})"
192228

229+
@property
230+
def data(self) -> dict[str, Any]:
231+
"""Return device data."""
232+
if self._sb_adv_data:
233+
return self._sb_adv_data.data
234+
return {}
235+
236+
@property
237+
def parsed_data(self) -> dict[str, Any]:
238+
"""Return parsed device data."""
239+
return self.data.get("data") or {}
240+
193241
@property
194242
def rssi(self) -> int:
195243
"""Return RSSI of device."""
@@ -392,6 +440,7 @@ def _override_state(self, state: dict[str, Any]) -> None:
392440
if self._override_adv_data is None:
393441
self._override_adv_data = {}
394442
self._override_adv_data.update(state)
443+
self._update_parsed_data(state)
395444

396445
def _get_adv_value(self, key: str) -> Any:
397446
"""Return value from advertisement data."""
@@ -466,8 +515,20 @@ def _unsub() -> None:
466515

467516
return _unsub
468517

469-
async def update(self) -> None:
470-
"""Update state of device."""
518+
async def update(self, interface: int | None = None) -> None:
519+
"""Update position, battery percent and light level of device."""
520+
if info := await self.get_basic_info():
521+
self._last_full_update = time.monotonic()
522+
self._update_parsed_data(info)
523+
524+
async def get_basic_info(self) -> dict[str, Any] | None:
525+
"""Get device basic settings."""
526+
if not (_data := await self._get_basic_info()):
527+
return None
528+
return {
529+
"battery": _data[1],
530+
"firmware": _data[2] / 10.0,
531+
}
471532

472533
def _check_command_result(
473534
self, result: bytes | None, index: int, values: set[int]
@@ -480,21 +541,54 @@ def _check_command_result(
480541
)
481542
return result[index] in values
482543

544+
def _update_parsed_data(self, new_data: dict[str, Any]) -> None:
545+
"""Update data."""
546+
if not self._sb_adv_data:
547+
_LOGGER.exception("No advertisement data to update")
548+
return
549+
self._set_parsed_data(
550+
self._sb_adv_data,
551+
_merge_data(self._sb_adv_data.data.get("data") or {}, new_data),
552+
)
553+
554+
def _set_parsed_data(
555+
self, advertisement: SwitchBotAdvertisement, data: dict[str, Any]
556+
) -> None:
557+
"""Set data."""
558+
self._sb_adv_data = replace(
559+
advertisement, data=self._sb_adv_data.data | {"data": data}
560+
)
561+
483562
def _set_advertisement_data(self, advertisement: SwitchBotAdvertisement) -> None:
484563
"""Set advertisement data."""
485-
if (
486-
advertisement.data.get("data")
487-
or not self._sb_adv_data
488-
or not self._sb_adv_data.data.get("data")
489-
):
564+
new_data = advertisement.data.get("data") or {}
565+
if advertisement.active:
566+
# If we are getting active data, we can assume we are
567+
# getting active scans and we do not need to poll
568+
self._last_full_update = time.monotonic()
569+
if not self._sb_adv_data:
490570
self._sb_adv_data = advertisement
571+
elif new_data:
572+
self._update_parsed_data(new_data)
491573
self._override_adv_data = None
492574

493575
def switch_mode(self) -> bool | None:
494576
"""Return true or false from cache."""
495577
# To get actual position call update() first.
496578
return self._get_adv_value("switchMode")
497579

580+
def poll_needed(self, seconds_since_last_poll: float | None) -> bool:
581+
"""Return if device needs polling."""
582+
if (
583+
seconds_since_last_poll is not None
584+
and seconds_since_last_poll < PASSIVE_POLL_INTERVAL
585+
):
586+
return False
587+
time_since_last_full_update = time.monotonic() - self._last_full_update
588+
if time_since_last_full_update < PASSIVE_POLL_INTERVAL:
589+
return False
590+
return True
591+
498592

499593
class SwitchbotDevice(SwitchbotBaseDevice):
500594
"""Base Representation of a Switchbot Device.

switchbot/devices/humidifier.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Library to handle connection with Switchbot."""
22
from __future__ import annotations
33

4+
import time
5+
46
from .device import REQ_HEADER, SwitchbotDevice
57

68
HUMIDIFIER_COMMAND_HEADER = "4381"
@@ -28,7 +30,8 @@ class SwitchbotHumidifier(SwitchbotDevice):
2830

2931
async def update(self, interface: int | None = None) -> None:
3032
"""Update state of device."""
31-
await self.get_device_data(retry=self._retry_count, interface=interface)
33+
# No battery here
34+
self._last_full_update = time.monotonic()
3235

3336
def _generate_command(
3437
self, on: bool | None = None, level: int | None = None
@@ -96,3 +99,7 @@ def get_target_humidity(self) -> int | None:
9699
if self.is_auto():
97100
return None
98101
return MANUAL_BUTTON_PRESSES_TO_LEVEL.get(level, level)
102+
103+
def poll_needed(self, last_poll_time: float | None) -> bool:
104+
"""Return if device needs polling."""
105+
return False

0 commit comments

Comments
 (0)