Skip to content

Commit 24eadf6

Browse files
committed
rework mqtt sink according to update aiomqtt library
1 parent cddde30 commit 24eadf6

2 files changed

Lines changed: 131 additions & 102 deletions

File tree

smartmeter_datacollector/sinks/mqtt_sink.py

Lines changed: 89 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@
55
# SPDX-License-Identifier: GPL-2.0-only
66
# See LICENSES/README.md for more information.
77
#
8+
import asyncio
89
import json
910
import logging
1011
import ssl
1112
from configparser import SectionProxy
1213
from dataclasses import dataclass
1314
from typing import Optional
1415

15-
from asyncio_mqtt import Client
16-
from asyncio_mqtt.client import ProtocolVersion
17-
from asyncio_mqtt.error import MqttCodeError, MqttError
18-
from paho.mqtt.client import MQTT_ERR_NO_CONN
16+
from aiomqtt import Client, MqttCodeError, MqttError
1917

20-
from smartmeter_datacollector.smartmeter.meter_data import MeterDataPoint
2118
from smartmeter_datacollector.sinks.data_sink import DataSink
19+
from smartmeter_datacollector.smartmeter.meter_data import MeterDataPoint
2220

2321
LOGGER = logging.getLogger("sink")
2422

@@ -31,13 +29,14 @@ class MqttConfig:
3129
use_tls: bool = False
3230
username: Optional[str] = None
3331
password: Optional[str] = None
34-
client_id: Optional[str] = None
3532
ca_cert_path: Optional[str] = None
3633
check_hostname: bool = True
3734
client_cert_path: Optional[str] = None
3835
client_key_path: Optional[str] = None
3936

40-
def with_tls(self, ca_cert_path: Optional[str] = None, check_hostname: bool = True) -> "MqttConfig":
37+
def with_tls(
38+
self, ca_cert_path: Optional[str] = None, check_hostname: bool = True
39+
) -> "MqttConfig":
4140
self.use_tls = True
4241
self.ca_cert_path = ca_cert_path
4342
self.check_hostname = check_hostname
@@ -56,23 +55,29 @@ def with_client_cert_auth(self, cert_path: str, key_path: str) -> "MqttConfig":
5655

5756
@staticmethod
5857
def from_sink_config(config: SectionProxy) -> "MqttConfig":
59-
mqtt_cfg = MqttConfig(config.get("host"), config.getint("port", 1883))
58+
host = config.get("host")
59+
if host is None:
60+
raise ValueError("MQTT config: 'host' must be set")
61+
port = config.getint("port", 1883)
62+
mqtt_cfg = MqttConfig(host, port)
6063
if config.getboolean("tls", fallback=False):
61-
mqtt_cfg.with_tls(config.get("ca_file_path"), config.getboolean("check_hostname", True))
62-
if config.get("username") and config.get("password"):
63-
mqtt_cfg.with_user_pass_auth(
64-
config.get("username"),
65-
config.get("password"))
66-
if config.get("client_cert_path") and config.get("client_key_path"):
67-
mqtt_cfg.with_client_cert_auth(
68-
config.get("client_cert_path"),
69-
config.get("client_key_path")
64+
mqtt_cfg.with_tls(
65+
config.get("ca_file_path"), config.getboolean("check_hostname", True)
7066
)
67+
username = config.get("username")
68+
password = config.get("password")
69+
if username is not None and password is not None:
70+
mqtt_cfg.with_user_pass_auth(username, password)
71+
client_cert_path = config.get("client_cert_path")
72+
client_key_path = config.get("client_key_path")
73+
if client_cert_path is not None and client_key_path is not None:
74+
mqtt_cfg.with_client_cert_auth(str(client_cert_path), str(client_key_path))
7175
return mqtt_cfg
7276

7377

7478
class MqttDataSink(DataSink):
7579
TIMEOUT = 3
80+
RETRIES = 2
7681

7782
def __init__(self, config: MqttConfig) -> None:
7883
tls_context = None
@@ -81,28 +86,26 @@ def __init__(self, config: MqttConfig) -> None:
8186
config.ca_cert_path,
8287
config.check_hostname,
8388
config.client_cert_path,
84-
config.client_key_path)
85-
86-
user_pass_auth = {}
87-
if config.username and config.password:
88-
user_pass_auth["username"] = config.username
89-
user_pass_auth["password"] = config.password
89+
config.client_key_path,
90+
)
9091

9192
self._client = Client(
9293
hostname=config.broker_host,
9394
port=config.port,
94-
client_id=config.client_id,
95+
username=config.username,
96+
password=config.password,
97+
timeout=self.TIMEOUT,
9598
tls_context=tls_context,
96-
protocol=ProtocolVersion.V311,
97-
clean_session=True,
98-
**user_pass_auth)
99+
)
100+
101+
self._client_task: Optional[asyncio.Task] = None
99102

100103
@staticmethod
101104
def _build_ssl_context(
102105
ca_file_path: Optional[str] = None,
103106
check_hostname: bool = True,
104107
client_cert_path: Optional[str] = None,
105-
client_key_path: Optional[str] = None
108+
client_key_path: Optional[str] = None,
106109
) -> ssl.SSLContext:
107110
context = ssl.create_default_context(cafile=ca_file_path)
108111

@@ -113,61 +116,78 @@ def _build_ssl_context(
113116
try:
114117
context.load_cert_chain(client_cert_path, client_key_path, None)
115118
except ssl.SSLError as ex:
116-
LOGGER.error("Client certificate does not match with the key and is ignored. '%s'", ex)
119+
LOGGER.error("Client certificate does not match with the key and is ignored ('%s')", ex)
117120
return context
118121

119122
async def start(self) -> None:
120-
if await self._connect_to_server():
121-
LOGGER.info("Connected to MQTT broker.")
123+
if self._client_task is not None:
124+
LOGGER.warning("MQTT client task is already running")
125+
return
126+
self._client_task = asyncio.create_task(self._connection_handler())
127+
LOGGER.info("Connecting to MQTT broker...")
122128

123129
async def stop(self) -> None:
124-
await self._disconnect_from_server()
125-
LOGGER.info("Disconnected from MQTT broker.")
130+
if self._client_task is None:
131+
LOGGER.warning("MQTT client task is not running")
132+
return
133+
LOGGER.info("Disconnecting from MQTT broker...")
134+
self._client_task.cancel()
135+
try:
136+
await self._client_task
137+
except asyncio.CancelledError:
138+
pass
139+
self._client_task = None
140+
LOGGER.info("Disconnected from MQTT broker")
126141

127142
async def send(self, data_point: MeterDataPoint) -> None:
128143
topic = MqttDataSink.get_topic_name_for_datapoint(data_point)
129144
dp_json = self.data_point_to_mqtt_json(data_point)
130-
try:
131-
await self._client.publish(topic, dp_json)
132-
LOGGER.debug("%s sent to MQTT broker.", dp_json)
133-
except ValueError as ex:
134-
LOGGER.error("MQTT payload or topic is invalid: '%s'", ex)
135-
except MqttCodeError as ex:
136-
if ex.rc == MQTT_ERR_NO_CONN:
137-
if await self._connect_to_server():
138-
LOGGER.info("Reconnected to MQTT broker.")
139-
try:
140-
await self._client.publish(topic, dp_json)
141-
LOGGER.debug("%s sent to MQTT broker.", dp_json)
142-
except MqttError:
143-
LOGGER.warning("MQTT message not sent.")
144-
else:
145-
LOGGER.error("MQTT message sending error: '%s'", ex)
146-
except MqttError as ex:
147-
LOGGER.error("MQTT message sending error: '%s'", ex)
148-
149-
async def _connect_to_server(self) -> bool:
150-
try:
151-
await self._client.connect(timeout=self.TIMEOUT)
152-
except MqttError as ex:
153-
LOGGER.error(ex)
154-
return False
155-
return True
156145

157-
async def _disconnect_from_server(self) -> None:
158-
try:
159-
await self._client.disconnect(timeout=self.TIMEOUT)
160-
except MqttError as ex:
161-
LOGGER.error(ex)
162-
await self._client.force_disconnect()
146+
await self._publish_with_retries(topic, dp_json, retries=self.RETRIES)
147+
148+
async def _connection_handler(self) -> None:
149+
while True:
150+
try:
151+
async with self._client:
152+
LOGGER.info("Connected to MQTT broker using client ID '%s'", self._client.identifier)
153+
async for msg in self._client.messages:
154+
# just used to keep the connection alive; no messages are expected
155+
LOGGER.debug("Received message on topic '%s': %s", msg.topic, msg.payload)
156+
except MqttError:
157+
LOGGER.warning("No connection to MQTT broker. Reconnecting in 1 second...")
158+
await asyncio.sleep(1)
159+
except asyncio.CancelledError:
160+
LOGGER.debug("MQTT client task cancelled")
161+
break
162+
163+
async def _publish_with_retries(self, topic: str, payload: str, retries: int = 1) -> None:
164+
for attempt in range(retries + 1):
165+
try:
166+
await self._client.publish(topic, payload)
167+
LOGGER.debug("Published MQTT msg with topic '%s': %s", topic, payload)
168+
return
169+
except ValueError as ex:
170+
LOGGER.error("MQTT payload or topic is invalid: '%s'", ex)
171+
return
172+
except TimeoutError:
173+
LOGGER.warning("MQTT publish attempt %d failed: not connected to broker", attempt + 1)
174+
except (MqttCodeError, MqttError) as ex:
175+
LOGGER.warning("MQTT publish attempt %d failed: %s", attempt + 1, ex)
176+
if attempt < retries:
177+
await asyncio.sleep(1)
178+
179+
LOGGER.error("Failed to publish MQTT msg with topic '%s' after %d attempts. Discarding msg",
180+
topic, retries + 1)
163181

164182
@staticmethod
165183
def get_topic_name_for_datapoint(data_point: MeterDataPoint) -> str:
166184
return f"smartmeter/{data_point.source}/{data_point.type.identifier}"
167185

168186
@staticmethod
169187
def data_point_to_mqtt_json(data_point: MeterDataPoint) -> str:
170-
return json.dumps({
171-
"value": data_point.value,
172-
"timestamp": int(data_point.timestamp.timestamp())
173-
})
188+
return json.dumps(
189+
{
190+
"value": data_point.value,
191+
"timestamp": int(data_point.timestamp.timestamp()),
192+
}
193+
)

tests/test_mqtt_sink.py

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,68 +7,74 @@
77
#
88
import configparser
99
import json
10-
import sys
1110
from datetime import datetime, timezone
1211
from unittest import mock
1312

1413
import pytest
15-
from asyncio_mqtt.error import MqttCodeError
14+
import pytest_mock
15+
from aiomqtt import MqttCodeError
1616
from paho.mqtt.client import MQTT_ERR_NO_CONN
17-
from pytest_mock.plugin import MockerFixture
1817

1918
from smartmeter_datacollector.sinks.mqtt_sink import MqttConfig, MqttDataSink
2019
from smartmeter_datacollector.smartmeter.meter_data import MeterDataPoint, MeterDataPointType
2120

22-
TEST_TYPE = MeterDataPointType("TEST_TYPE", "test type", "unit")
21+
TEST_DATA_POINT_TYPE = MeterDataPointType("TEST_TYPE", "test type", "unit")
2322

2423

25-
@pytest.mark.asyncio
26-
async def test_mqtt_sink_start_stop(mocker: MockerFixture):
27-
config = MqttConfig("localhost")
28-
sink = MqttDataSink(config)
29-
client_mock = mocker.patch.object(sink, "_client", autospec=True)
30-
31-
await sink.start()
32-
client_mock.connect.assert_awaited_once()
33-
34-
await sink.stop()
35-
client_mock.disconnect.assert_awaited_once()
24+
@pytest.fixture(autouse=True)
25+
def mocked_mqtt_client(mocker: pytest_mock.MockerFixture):
26+
mock_client_class = mocker.patch("smartmeter_datacollector.sinks.mqtt_sink.Client", autospec=True)
27+
return mock_client_class.return_value
3628

3729

3830
@pytest.mark.asyncio
39-
async def test_mqtt_sink_send_point_when_started(mocker: MockerFixture):
31+
async def test_mqtt_sink_send_datapoint(mocked_mqtt_client):
4032
config = MqttConfig("localhost")
4133
sink = MqttDataSink(config)
42-
client_mock = mocker.patch.object(sink, "_client", autospec=True)
43-
data_point = MeterDataPoint(TEST_TYPE, 1.0, "test_source", datetime.now(timezone.utc))
44-
expected_topic = f"smartmeter/test_source/{TEST_TYPE.identifier}"
34+
35+
data_point = MeterDataPoint(TEST_DATA_POINT_TYPE, 1.0, "test_source", datetime.now(timezone.utc))
36+
expected_topic = f"smartmeter/test_source/{TEST_DATA_POINT_TYPE.identifier}"
4537
expected_payload = json.dumps({
4638
"value": data_point.value,
4739
"timestamp": int(data_point.timestamp.timestamp()),
4840
})
4941

50-
await sink.start()
5142
await sink.send(data_point)
5243

53-
client_mock.publish.assert_awaited_once_with(expected_topic, expected_payload)
44+
mocked_mqtt_client.publish.assert_awaited_with(expected_topic, expected_payload)
5445

5546

5647
@pytest.mark.asyncio
57-
async def test_mqtt_sink_send_reconnect_when_not_started(mocker: MockerFixture):
48+
async def test_mqtt_sink_retry_sending_datapoint(mocked_mqtt_client: mock.MagicMock):
5849
config = MqttConfig("localhost")
5950
sink = MqttDataSink(config)
60-
client_mock = mocker.patch.object(sink, "_client", autospec=True)
61-
data_point = MeterDataPoint(TEST_TYPE, 1.0, "test_source", datetime.now(timezone.utc))
51+
data_point = MeterDataPoint(TEST_DATA_POINT_TYPE, 1.0, "test_source", datetime.now(timezone.utc))
6252

63-
client_mock.publish.side_effect = MqttCodeError(MQTT_ERR_NO_CONN)
64-
await sink.send(data_point)
53+
# Simulate publish raising MqttCodeError the first time, then succeeding
54+
mocked_mqtt_client.publish.side_effect = [MqttCodeError(MQTT_ERR_NO_CONN), None]
55+
56+
with mock.patch("smartmeter_datacollector.sinks.mqtt_sink.asyncio.sleep"):
57+
await sink.send(data_point)
58+
59+
assert mocked_mqtt_client.publish.await_count == 2
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_mqtt_sink_only_retry_sending_3_times(mocked_mqtt_client: mock.MagicMock):
64+
config = MqttConfig("localhost")
65+
sink = MqttDataSink(config)
66+
data_point = MeterDataPoint(TEST_DATA_POINT_TYPE, 1.0, "test_source", datetime.now(timezone.utc))
67+
68+
mocked_mqtt_client.publish.side_effect = [MqttCodeError(MQTT_ERR_NO_CONN)]*4
69+
70+
with mock.patch("smartmeter_datacollector.sinks.mqtt_sink.asyncio.sleep"):
71+
await sink.send(data_point)
6572

66-
client_mock.publish.assert_awaited()
67-
assert client_mock.publish.await_count == 2
68-
client_mock.connect.assert_awaited_once()
73+
assert mocked_mqtt_client.publish.await_count == 3
6974

7075

71-
def test_mqtt_config_unencrypted_unauthorized():
76+
@mock.patch("smartmeter_datacollector.sinks.mqtt_sink.Client", new_callable=mock.MagicMock)
77+
def test_mqtt_config_unencrypted_unauthorized(_: mock.MagicMock):
7278
cfg_parser = configparser.ConfigParser()
7379
cfg_parser.read_dict({
7480
"sink": {
@@ -91,7 +97,8 @@ def test_mqtt_config_unencrypted_unauthorized():
9197
sink = MqttDataSink(cfg)
9298

9399

94-
def test_mqtt_config_encrypted_unauthorized():
100+
@mock.patch("smartmeter_datacollector.sinks.mqtt_sink.Client", new_callable=mock.MagicMock)
101+
def test_mqtt_config_encrypted_unauthorized(_: mock.MagicMock):
95102
cfg_parser = configparser.ConfigParser()
96103
cfg_parser.read_dict({
97104
"sink": {
@@ -117,7 +124,8 @@ def test_mqtt_config_encrypted_unauthorized():
117124
sink = MqttDataSink(cfg)
118125

119126

120-
def test_mqtt_config_encrypted_authorized_user_pass():
127+
@mock.patch("smartmeter_datacollector.sinks.mqtt_sink.Client", new_callable=mock.MagicMock)
128+
def test_mqtt_config_encrypted_authorized_user_pass(_: mock.MagicMock):
121129
cfg_parser = configparser.ConfigParser()
122130
cfg_parser.read_dict({
123131
"sink": {
@@ -146,7 +154,8 @@ def test_mqtt_config_encrypted_authorized_user_pass():
146154
sink = MqttDataSink(cfg)
147155

148156

149-
def test_mqtt_config_encrypted_authorized_client_cert():
157+
@mock.patch("smartmeter_datacollector.sinks.mqtt_sink.Client", new_callable=mock.MagicMock)
158+
def test_mqtt_config_encrypted_authorized_client_cert(_: mock.MagicMock):
150159
cfg_parser = configparser.ConfigParser()
151160
cfg_parser.read_dict({
152161
"sink": {

0 commit comments

Comments
 (0)