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
4 changes: 3 additions & 1 deletion src/wyzeapy/services/base_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ class BaseService:
_last_updated_time: time = (
0 # preload a value of 0 so that comparison will succeed on the first run
)
_min_update_time = 1200 # lets let the device_params update every 20 minutes for now. This could probably reduced signicficantly.
_min_update_time = (
60 # seconds; lowered from 1200 as part of the sensor rate-limit fix
)
_update_lock: asyncio.Lock = asyncio.Lock() # fmt: skip
_update_manager: UpdateManager = UpdateManager()
_update_loop = None
Expand Down
28 changes: 22 additions & 6 deletions src/wyzeapy/services/camera_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,20 @@ def __init__(self, dictionary: Dict[Any, Any]):
class CameraService(BaseService):
_updater_thread: Optional[Thread] = None
_subscribers: List[Tuple[Camera, Callable[[Camera], None]]] = []
_worker_loop_interval = 5 # seconds between full passes

async def update(self, camera: Camera):
async def update(self, camera: Camera, latest_events: Optional[List[Event]] = None):
# Get updated device_params
async with BaseService._update_lock:
camera.device_params = await self.get_updated_params(camera.mac)

# Get camera events
response = await self._get_event_list(10)
raw_events = response["data"]["event_list"]
latest_events = [Event(raw_event) for raw_event in raw_events]
# Get camera events. When called from update_worker, latest_events is
# fetched once per pass and shared across all cameras, instead of
# each camera independently re-fetching the same account-wide list.
if latest_events is None:
response = await self._get_event_list(10)
raw_events = response["data"]["event_list"]
latest_events = [Event(raw_event) for raw_event in raw_events]

if (event := return_event_for_device(camera, latest_events)) is not None:
camera.last_event = event
Expand Down Expand Up @@ -138,11 +142,21 @@ def update_worker(self, loop):
if len(self._subscribers) < 1:
time.sleep(0.1)
else:
try:
response = asyncio.run_coroutine_threadsafe(
self._get_event_list(10), loop
).result()
raw_events = response["data"]["event_list"]
latest_events = [Event(raw_event) for raw_event in raw_events]
except (UnknownApiError, ClientOSError, ContentTypeError) as e:
_LOGGER.error(f"Failed to fetch shared event list: {e}")
latest_events = []

for camera, callback in self._subscribers:
try:
callback(
asyncio.run_coroutine_threadsafe(
self.update(camera), loop
self.update(camera, latest_events), loop
).result()
)
except UnknownApiError as e:
Expand All @@ -154,6 +168,8 @@ def update_worker(self, loop):
except ContentTypeError as e:
_LOGGER.error(f"Server returned unexpected ContentType: {e}")

time.sleep(self._worker_loop_interval)

async def get_cameras(self) -> List[Camera]:
if self._devices is None:
self._devices = await self.get_object_list()
Expand Down
29 changes: 29 additions & 0 deletions src/wyzeapy/services/sensor_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# katie@mulliken.net to receive a copy
import asyncio
import logging
import time
from threading import Thread
from typing import List, Callable, Tuple, Optional

Expand All @@ -24,11 +25,35 @@ class Sensor(Device):
class SensorService(BaseService):
_updater_thread: Optional[Thread] = None
_subscribers: List[Tuple[Sensor, Callable[[Sensor], None]]] = []
_worker_loop_interval = 5 # seconds between full passes; actual API
# calls are separately bounded by BaseService._min_update_time

async def update(self, sensor: Sensor) -> Sensor:
# Get updated device_params
async with BaseService._update_lock:
sensor.device_params = await self.get_updated_params(sensor.mac)

if sensor.type is DeviceTypes.LEAK_SENSOR:
sensor.detected = sensor.device_params.get("ws_detect_state") == 1
return sensor
Comment on lines +37 to +38

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Refresh leak state before returning it

When a leak starts or stops within 1,200 seconds of the previous object-list refresh, get_updated_params() returns the cached BaseService._devices parameters, and this new early return skips the live property request. Consequently, normal polls and callbacks can report the previous wet/dry state for up to 20 minutes; temperature and humidity values have the same staleness problem. These parameter-only sensors need a suitably fresh API source or explicit cache invalidation.

Useful? React with 👍 / 👎.


if sensor.type is DeviceTypes.TEMPERATURE_HUMIDITY:
return sensor
Comment on lines +40 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Throttle parameter-only sensor update callbacks

When a leak or temperature/humidity sensor is registered through register_for_updates(), after the initial refresh this branch completes from the cache without network latency. Since update_worker() runs while True with no delay, it then schedules updates and invokes the subscriber callback continuously, potentially consuming a CPU core and flooding the event loop until the sensor is deregistered. Add explicit polling throttling for these early-return paths.

Useful? React with 👍 / 👎.


if sensor.type is DeviceTypes.CONTACT_SENSOR:
sensor.detected = sensor.device_params.get("open_close_state") == 1
return sensor

if sensor.type is DeviceTypes.MOTION_SENSOR:
sensor.detected = sensor.device_params.get("motion_state") == 1
return sensor

# Fallback for any sensor type not explicitly handled above.
# Currently unused by MOTION_SENSOR, CONTACT_SENSOR, LEAK_SENSOR, and
# TEMPERATURE_HUMIDITY, which all read state from the already-cached
# device_params via get_updated_params() instead of making a separate
# per-sensor API call. Kept for forward compatibility with future
# sensor types that may still need PropertyIDs-based property lookup.
properties = await self._get_device_info(sensor)

for property in properties["data"]["property_list"]:
Expand Down Expand Up @@ -88,6 +113,8 @@ def update_worker(self, loop):
except ContentTypeError as e:
_LOGGER.error(f"Server returned unexpected ContentType: {e}")

time.sleep(self._worker_loop_interval)

async def get_sensors(self) -> List[Sensor]:
if self._devices is None:
self._devices = await self.get_object_list()
Expand All @@ -97,5 +124,7 @@ async def get_sensors(self) -> List[Sensor]:
for device in self._devices
if device.type is DeviceTypes.MOTION_SENSOR
or device.type is DeviceTypes.CONTACT_SENSOR
or device.type is DeviceTypes.LEAK_SENSOR
or device.type is DeviceTypes.TEMPERATURE_HUMIDITY
]
return [Sensor(sensor.raw_dict) for sensor in sensors]
1 change: 1 addition & 0 deletions src/wyzeapy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class DeviceTypes(Enum):
CONTACT_SENSOR = "ContactSensor"
MOTION_SENSOR = "MotionSensor"
LEAK_SENSOR = "LeakSensor"
TEMPERATURE_HUMIDITY = "TemperatureHumidity"
WRIST = "Wrist"
BASE_STATION = "BaseStation"
SCALE = "WyzeScale"
Expand Down
8 changes: 4 additions & 4 deletions tests/test_camera_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,6 @@ async def test_update_worker_success(self):
async def test_update_worker_exceptions(self):
mock_callback = MagicMock()

# Create a series of exceptions that will be raised when update is called
exceptions_to_raise = [
UnknownApiError("API Error"),
ClientOSError(),
Expand All @@ -449,10 +448,14 @@ async def test_update_worker_exceptions(self):
]

self.camera_service.update = AsyncMock(side_effect=exceptions_to_raise)
self.camera_service._get_event_list = AsyncMock(
return_value={"data": {"event_list": []}}
)

with (
patch("wyzeapy.services.camera_service._LOGGER.warning") as mock_warning,
patch("wyzeapy.services.camera_service._LOGGER.error") as mock_error,
patch("wyzeapy.services.camera_service.time.sleep"),
):
await self.camera_service.register_for_updates(
self.test_camera, mock_callback
Expand All @@ -465,15 +468,12 @@ async def test_update_worker_exceptions(self):
self.camera_service._subscribers = []
self.camera_service._updater_thread.join(timeout=1)

# Check that the update method was called at least the number of exceptions we set up
self.assertGreaterEqual(
self.camera_service.update.call_count, len(exceptions_to_raise)
)
# Check that the warning was called for UnknownApiError
mock_warning.assert_called_with(
"The update method detected an UnknownApiError: API Error"
)
# Check that error was called for other exceptions
self.assertGreaterEqual(mock_error.call_count, 2)


Expand Down
Loading