Skip to content

Commit 29ad8a0

Browse files
emanCopilot
andcommitted
Address PR review comments
- client.py on_feature: move future.done() check inside call_soon_threadsafe callback to eliminate race between done-check and set_result across the SDK thread / event loop thread boundary - tests/test_mqtt_reconnection.py: replace asyncio.Future with concurrent.futures.Future to match what the AWS IoT SDK actually returns, exercising the wrap_future() conversion path in close() - encoding.py build_reservation_entry: read get_unit_system() once into a local variable instead of calling it twice Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 19f51e5 commit 29ad8a0

3 files changed

Lines changed: 34 additions & 19 deletions

File tree

src/nwp500/encoding.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -434,18 +434,21 @@ def build_reservation_entry(
434434
from .models import preferred_to_half_celsius
435435
from .unit_system import get_unit_system
436436

437+
# Read unit system once to keep min/max bounds consistent
438+
unit_system = get_unit_system()
439+
437440
# Use device-provided limits if available, otherwise use defaults
438441
# in the user's preferred unit system.
439442
if temperature_min is not None:
440443
min_temp = temperature_min
441-
elif get_unit_system() == "metric":
444+
elif unit_system == "metric":
442445
min_temp = 35.0 # ~35°C
443446
else:
444447
min_temp = 95.0 # 95°F
445448

446449
if temperature_max is not None:
447450
max_temp = temperature_max
448-
elif get_unit_system() == "metric":
451+
elif unit_system == "metric":
449452
max_temp = 65.0 # ~65°C
450453
else:
451454
max_temp = 150.0 # 150°F

src/nwp500/mqtt/client.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,10 +1300,16 @@ async def ensure_device_info_cached(
13001300
future: asyncio.Future[DeviceFeature] = loop.create_future()
13011301

13021302
def on_feature(feature: DeviceFeature) -> None:
1303-
# Called from AWS SDK thread — must use thread-safe method
1304-
if not future.done():
1305-
_logger.info(f"Device feature received for {redacted_mac}")
1306-
loop.call_soon_threadsafe(future.set_result, feature)
1303+
# Called from AWS SDK thread — schedule onto the event loop
1304+
# thread-safely. The done() check is inside the scheduled
1305+
# callback so it runs on the event loop thread, eliminating
1306+
# the race between the check and set_result.
1307+
def _set_result() -> None:
1308+
if not future.done():
1309+
_logger.info(f"Device feature received for {redacted_mac}")
1310+
future.set_result(feature)
1311+
1312+
loop.call_soon_threadsafe(_set_result)
13071313

13081314
_logger.info(f"Ensuring device info cached for {redacted_mac}")
13091315
await self.subscribe_device_feature(device, on_feature)

tests/test_mqtt_reconnection.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
import asyncio
5+
import concurrent.futures
66
from unittest.mock import MagicMock
77

88
import pytest
@@ -40,6 +40,16 @@ def config():
4040
return MqttConnectionConfig(client_id="test-client")
4141

4242

43+
def _make_cf_future(result=None, exception=None):
44+
"""Return a resolved concurrent.futures.Future matching SDK behaviour."""
45+
f = concurrent.futures.Future()
46+
if exception is not None:
47+
f.set_exception(exception)
48+
else:
49+
f.set_result(result)
50+
return f
51+
52+
4353
class TestMqttConnectionClose:
4454
"""Tests for MqttConnection.close() method."""
4555

@@ -55,15 +65,14 @@ async def test_close_on_none_connection(self, config, mock_auth_client):
5565

5666
@pytest.mark.asyncio(loop_scope="function")
5767
async def test_close_clears_state(self, config, mock_auth_client):
58-
"""close() should clear _connection and _connected regardless."""
68+
"""close() should clear _connection and _connected regardless.
69+
70+
Uses concurrent.futures.Future to match what the AWS IoT SDK
71+
returns from disconnect(), exercising the wrap_future() path.
72+
"""
5973
conn = MqttConnection(config, mock_auth_client)
60-
# Simulate a connection that was interrupted (_connected=False
61-
# but _connection still exists)
6274
mock_sdk_conn = MagicMock()
63-
loop = asyncio.get_running_loop()
64-
future = loop.create_future()
65-
future.set_result(None)
66-
mock_sdk_conn.disconnect.return_value = future
75+
mock_sdk_conn.disconnect.return_value = _make_cf_future()
6776
conn._connection = mock_sdk_conn
6877
conn._connected = False # Interrupted state
6978

@@ -97,16 +106,13 @@ async def test_close_handles_already_dead_connection(
97106

98107
conn = MqttConnection(config, mock_auth_client)
99108
mock_sdk_conn = MagicMock()
100-
loop = asyncio.get_running_loop()
101-
future = loop.create_future()
102-
future.set_exception(
103-
AwsCrtError(
109+
mock_sdk_conn.disconnect.return_value = _make_cf_future(
110+
exception=AwsCrtError(
104111
code=0,
105112
name="AWS_ERROR_MQTT_CONNECTION_DESTROYED",
106113
message="Connection destroyed",
107114
)
108115
)
109-
mock_sdk_conn.disconnect.return_value = future
110116
conn._connection = mock_sdk_conn
111117
conn._connected = False
112118

0 commit comments

Comments
 (0)