Skip to content

Commit d13f264

Browse files
authored
Wrap awscrt types out of public MQTT signatures (#101) (#105)
Introduce a library-owned QoS IntEnum and MqttConnectionHandle alias in nwp500/mqtt/types.py, translating to/from awscrt.mqtt.QoS only at the connection-layer boundary. Replace awscrt.mqtt.QoS and awscrt.mqtt.Connection type references across NavienMqttClient, MqttConnection, MqttSubscriptionManager, MqttCommandQueue and QueuedCommand. Wrap otherwise uncaught AwsCrtError in MqttPublishError at the public publish() boundary so awscrt exceptions no longer leak. Export QoS from nwp500 and nwp500.mqtt. Update tests to use the library QoS enum.
1 parent e057c6e commit d13f264

12 files changed

Lines changed: 125 additions & 48 deletions

CHANGELOG.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,29 @@ Changed
2525
is byte-for-byte unchanged (verified by golden capture and the existing
2626
CLI tests); energy output now renders a summary table plus a breakdown
2727
table entirely in Rich rather than printing plain text and a Rich table.
28+
- **awscrt types wrapped out of public MQTT signatures** (`#101
29+
<https://github.com/eman/nwp500-python/issues/101>`_): the library no
30+
longer exposes ``awscrt`` SDK types on its own public/semi-public API
31+
surface. A library-owned ``nwp500.mqtt.QoS`` ``IntEnum`` (also exported
32+
as ``nwp500.QoS``) now replaces ``awscrt.mqtt.QoS`` on the ``publish``
33+
and ``subscribe`` methods of ``NavienMqttClient``, ``MqttConnection``
34+
and ``MqttSubscriptionManager``, on ``MqttCommandQueue.enqueue`` and on
35+
the ``QueuedCommand`` dataclass. ``awscrt.mqtt.Connection`` handles are
36+
typed behind the ``MqttConnectionHandle`` alias, and translation to/from
37+
``awscrt`` happens only at the connection-layer boundary
38+
(``nwp500/mqtt/types.py``). ``NavienMqttClient.publish`` now wraps
39+
otherwise-uncaught ``AwsCrtError`` in ``MqttPublishError`` so ``awscrt``
40+
exceptions no longer leak out of the public boundary.
41+
42+
.. code-block:: python
43+
44+
# OLD
45+
from awscrt import mqtt
46+
await client.publish(topic, payload, qos=mqtt.QoS.AT_LEAST_ONCE)
47+
48+
# NEW
49+
from nwp500 import QoS
50+
await client.publish(topic, payload, qos=QoS.AT_LEAST_ONCE)
2851
2952
Fixed
3053
-----

src/nwp500/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@
110110
MqttMetrics,
111111
NavienMqttClient,
112112
PeriodicRequestType,
113+
QoS,
113114
)
114115
from nwp500.mqtt_events import (
115116
MqttClientEvents,
@@ -221,6 +222,7 @@
221222
"MqttMetrics",
222223
"ConnectionDropEvent",
223224
"ConnectionEvent",
225+
"QoS",
224226
# Event Emitter
225227
"EventEmitter",
226228
"EventListener",

src/nwp500/mqtt/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- PeriodicRequestType: Enum for periodic request types
1010
- MqttDiagnosticsCollector: Metrics and diagnostics collector
1111
- MqttMetrics, ConnectionDropEvent, ConnectionEvent: Diagnostic types
12+
- QoS: Library-owned MQTT Quality of Service enum
1213
"""
1314

1415
from .client import NavienMqttClient
@@ -18,6 +19,7 @@
1819
MqttDiagnosticsCollector,
1920
MqttMetrics,
2021
)
22+
from .types import QoS
2123
from .utils import MqttConnectionConfig, PeriodicRequestType
2224

2325
__all__ = [
@@ -28,4 +30,5 @@
2830
"MqttMetrics",
2931
"ConnectionDropEvent",
3032
"ConnectionEvent",
33+
"QoS",
3134
]

src/nwp500/mqtt/client.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from collections.abc import Callable, Sequence
1717
from typing import TYPE_CHECKING, Any, cast
1818

19-
from awscrt import mqtt
2019
from awscrt.exceptions import AwsCrtError
2120

2221
from ..auth import NavienAuthClient
@@ -41,6 +40,7 @@
4140
from .periodic import MqttPeriodicRequestManager
4241
from .reconnection import MqttReconnectionHandler
4342
from .subscriptions import MqttSubscriptionManager
43+
from .types import MqttConnectionHandle, QoS
4444
from .utils import (
4545
MqttConnectionConfig,
4646
PeriodicRequestType,
@@ -246,7 +246,7 @@ def __init__(
246246
self._diagnostics = MqttDiagnosticsCollector()
247247

248248
# Connection state (simpler than checking _connection_manager)
249-
self._connection: mqtt.Connection | None = None
249+
self._connection: MqttConnectionHandle | None = None
250250
self._connected = False
251251
# Guards _active_reconnect / _deep_reconnect against re-entrancy.
252252
# While True, _on_connection_interrupted_internal will not forward
@@ -294,7 +294,7 @@ def _schedule_coroutine(self, coro: Any) -> None:
294294
future.add_done_callback(_log_scheduled_coroutine_result)
295295

296296
def _on_connection_interrupted_internal(
297-
self, connection: mqtt.Connection, error: AwsCrtError, **kwargs: Any
297+
self, connection: MqttConnectionHandle, error: Exception, **kwargs: Any
298298
) -> None:
299299
"""Internal handler for connection interruption.
300300
@@ -350,7 +350,7 @@ def _on_connection_interrupted_internal(
350350

351351
def _on_connection_resumed_internal(
352352
self,
353-
connection: mqtt.Connection,
353+
connection: MqttConnectionHandle,
354354
return_code: Any,
355355
session_present: Any,
356356
**kwargs: Any,
@@ -885,7 +885,7 @@ async def subscribe(
885885
self,
886886
topic: str,
887887
callback: Callable[[str, dict[str, Any]], None],
888-
qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE,
888+
qos: QoS = QoS.AT_LEAST_ONCE,
889889
) -> int:
890890
"""
891891
Subscribe to an MQTT topic.
@@ -930,7 +930,7 @@ async def publish(
930930
self,
931931
topic: str,
932932
payload: dict[str, Any],
933-
qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE,
933+
qos: QoS = QoS.AT_LEAST_ONCE,
934934
) -> int:
935935
"""
936936
Publish a message to an MQTT topic.
@@ -990,9 +990,13 @@ async def publish(
990990
retriable=True,
991991
) from e
992992

993-
# Other AWS CRT errors
993+
# Other AWS CRT errors: wrap so awscrt exceptions never leak out
994+
# of the public publish() boundary.
994995
_logger.error(f"Failed to publish to topic: {e}")
995-
raise
996+
raise MqttPublishError(
997+
f"Failed to publish to MQTT topic: {e}",
998+
retriable=True,
999+
) from e
9961000

9971001
# Navien-specific convenience methods
9981002

src/nwp500/mqtt/command_queue.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
from datetime import UTC, datetime
1212
from typing import TYPE_CHECKING, Any
1313

14-
from awscrt import mqtt
15-
14+
from .types import QoS
1615
from .utils import QueuedCommand, redact_topic
1716

1817
if TYPE_CHECKING:
@@ -54,9 +53,7 @@ def __init__(self, config: MqttConnectionConfig):
5453
maxlen=config.max_queued_commands
5554
)
5655

57-
def enqueue(
58-
self, topic: str, payload: dict[str, Any], qos: mqtt.QoS
59-
) -> None:
56+
def enqueue(self, topic: str, payload: dict[str, Any], qos: QoS) -> None:
6057
"""
6158
Add a command to the queue.
6259

src/nwp500/mqtt/connection.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@
1414
from collections.abc import Callable
1515
from typing import TYPE_CHECKING, Any
1616

17-
from awscrt import mqtt
1817
from awscrt.exceptions import AwsCrtError
1918
from awsiot import mqtt_connection_builder
2019

2120
from ..exceptions import (
2221
MqttCredentialsError,
2322
MqttNotConnectedError,
2423
)
24+
from .types import MqttConnectionHandle, QoS, to_awscrt_qos
2525

2626
if TYPE_CHECKING:
2727
from ..auth import NavienAuthClient
@@ -50,10 +50,10 @@ def __init__(
5050
config: MqttConnectionConfig,
5151
auth_client: NavienAuthClient,
5252
on_connection_interrupted: (
53-
Callable[[mqtt.Connection, AwsCrtError], None] | None
53+
Callable[[MqttConnectionHandle, Exception], None] | None
5454
) = None,
5555
on_connection_resumed: (
56-
Callable[[mqtt.Connection, Any, Any | None], None] | None
56+
Callable[[MqttConnectionHandle, Any, Any | None], None] | None
5757
) = None,
5858
):
5959
"""
@@ -87,7 +87,7 @@ def __init__(
8787

8888
self.config = config
8989
self._auth_client = auth_client
90-
self._connection: mqtt.Connection | None = None
90+
self._connection: MqttConnectionHandle | None = None
9191
self._connected = False
9292
self._on_connection_interrupted = on_connection_interrupted
9393
self._on_connection_resumed = on_connection_resumed
@@ -350,7 +350,7 @@ async def close(self) -> None:
350350
async def subscribe(
351351
self,
352352
topic: str,
353-
qos: mqtt.QoS,
353+
qos: QoS,
354354
callback: Callable[..., None] | None = None,
355355
) -> tuple[Any, int]:
356356
"""
@@ -376,7 +376,7 @@ async def subscribe(
376376
# Use shield to prevent cancellation from propagating to
377377
# underlying future
378378
subscribe_future_raw, packet_id_raw = self._connection.subscribe(
379-
topic=topic, qos=qos, callback=callback
379+
topic=topic, qos=to_awscrt_qos(qos), callback=callback
380380
)
381381
subscribe_future = subscribe_future_raw
382382
packet_id = packet_id_raw
@@ -422,7 +422,7 @@ async def publish(
422422
self,
423423
topic: str,
424424
payload: str | dict[str, Any],
425-
qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE,
425+
qos: QoS = QoS.AT_LEAST_ONCE,
426426
) -> int:
427427
"""
428428
Publish a message to an MQTT topic.
@@ -453,7 +453,7 @@ async def publish(
453453

454454
# Publish and get the concurrent.futures.Future
455455
publish_future_raw, packet_id_raw = self._connection.publish(
456-
topic=topic, payload=payload_bytes, qos=qos
456+
topic=topic, payload=payload_bytes, qos=to_awscrt_qos(qos)
457457
)
458458
publish_future = publish_future_raw
459459
packet_id = int(packet_id_raw)
@@ -488,7 +488,7 @@ def is_connected(self) -> bool:
488488
return self._connected
489489

490490
@property
491-
def connection(self) -> mqtt.Connection | None:
491+
def connection(self) -> MqttConnectionHandle | None:
492492
"""Get the underlying MQTT connection.
493493
494494
Returns:

src/nwp500/mqtt/subscriptions.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from collections.abc import Callable
1717
from typing import TYPE_CHECKING, Any, cast
1818

19-
from awscrt import mqtt
2019
from awscrt.exceptions import AwsCrtError
2120
from pydantic import ValidationError
2221

@@ -35,6 +34,7 @@
3534
from ..mqtt_events import FeatureReceivedEvent, StatusReceivedEvent
3635
from ..topic_builder import MqttTopicBuilder
3736
from .state_tracker import DeviceStateTracker
37+
from .types import QoS, to_awscrt_qos
3838
from .utils import get_response_data, redact_topic, topic_matches_pattern
3939

4040
if TYPE_CHECKING:
@@ -87,7 +87,7 @@ def __init__(
8787
self._operation_timeout = operation_timeout
8888

8989
# Track subscriptions and handlers
90-
self._subscriptions: dict[str, mqtt.QoS] = {}
90+
self._subscriptions: dict[str, QoS] = {}
9191
self._message_handlers: dict[
9292
str, list[Callable[[str, dict[str, Any]], None]]
9393
] = {}
@@ -96,7 +96,7 @@ def __init__(
9696
self._state_tracker = DeviceStateTracker(event_emitter)
9797

9898
@property
99-
def subscriptions(self) -> dict[str, mqtt.QoS]:
99+
def subscriptions(self) -> dict[str, QoS]:
100100
"""Get current subscriptions."""
101101
return self._subscriptions.copy()
102102

@@ -205,7 +205,7 @@ async def subscribe(
205205
self,
206206
topic: str,
207207
callback: Callable[[str, dict[str, Any]], None],
208-
qos: mqtt.QoS = mqtt.QoS.AT_LEAST_ONCE,
208+
qos: QoS = QoS.AT_LEAST_ONCE,
209209
) -> int:
210210
"""
211211
Subscribe to an MQTT topic.
@@ -247,7 +247,9 @@ async def subscribe(
247247
# callbacks (avoids InvalidStateError); the underlying
248248
# subscribe completes independently.
249249
subscribe_future, packet_id = self._connection.subscribe(
250-
topic=topic, qos=qos, callback=self._on_message_received
250+
topic=topic,
251+
qos=to_awscrt_qos(qos),
252+
callback=self._on_message_received,
251253
)
252254
awaitable_future = asyncio.wrap_future(subscribe_future)
253255
try:
@@ -457,7 +459,7 @@ async def resubscribe_all(self) -> None:
457459
qos_map = dict(subscriptions_to_restore)
458460
for topic in failed_subscriptions:
459461
self._subscriptions[topic] = qos_map.get(
460-
topic, mqtt.QoS.AT_LEAST_ONCE
462+
topic, QoS.AT_LEAST_ONCE
461463
)
462464
self._message_handlers[topic] = handlers_to_restore.get(
463465
topic, []

src/nwp500/mqtt/types.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Library-owned MQTT transport types.
2+
3+
This module defines transport-level types that the library exposes on its own
4+
public and internal signatures instead of leaking ``awscrt`` SDK types. It is
5+
the single boundary where the library's own :class:`QoS` enum is translated
6+
to and from :class:`awscrt.mqtt.QoS`, keeping ``awscrt`` an implementation
7+
detail that could be swapped for a different MQTT transport in the future.
8+
"""
9+
10+
from enum import IntEnum
11+
12+
from awscrt import mqtt
13+
14+
__author__ = "Emmanuel Levijarvi"
15+
__copyright__ = "Emmanuel Levijarvi"
16+
__license__ = "MIT"
17+
18+
19+
class QoS(IntEnum):
20+
"""MQTT Quality of Service level.
21+
22+
Mirrors the MQTT specification's QoS levels. Integer values match the
23+
wire protocol (and :class:`awscrt.mqtt.QoS`) so translation is a direct
24+
value mapping.
25+
"""
26+
27+
AT_MOST_ONCE = 0
28+
"""QoS 0: fire-and-forget delivery with no acknowledgement."""
29+
30+
AT_LEAST_ONCE = 1
31+
"""QoS 1: acknowledged delivery; duplicates possible."""
32+
33+
EXACTLY_ONCE = 2
34+
"""QoS 2: acknowledged, exactly-once delivery."""
35+
36+
37+
# Opaque handle for the underlying MQTT connection object. Typed as an alias so
38+
# callers and internal code refer to the library's name rather than the
39+
# concrete ``awscrt`` type, keeping the transport swappable.
40+
type MqttConnectionHandle = mqtt.Connection
41+
42+
43+
def to_awscrt_qos(qos: QoS) -> mqtt.QoS:
44+
"""Translate a library :class:`QoS` to :class:`awscrt.mqtt.QoS`."""
45+
return mqtt.QoS(int(qos))
46+
47+
48+
def from_awscrt_qos(qos: mqtt.QoS) -> QoS:
49+
"""Translate an :class:`awscrt.mqtt.QoS` to a library :class:`QoS`."""
50+
return QoS(int(qos))

src/nwp500/mqtt/utils.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,8 @@
1212
from enum import StrEnum
1313
from typing import Any
1414

15-
from awscrt import mqtt
16-
1715
from ..config import AWS_IOT_ENDPOINT, AWS_REGION
16+
from .types import QoS
1817

1918
__author__ = "Emmanuel Levijarvi"
2019
__copyright__ = "Emmanuel Levijarvi"
@@ -286,7 +285,7 @@ class QueuedCommand:
286285

287286
topic: str
288287
payload: dict[str, Any]
289-
qos: mqtt.QoS
288+
qos: QoS
290289
timestamp: datetime
291290

292291

0 commit comments

Comments
 (0)