55# SPDX-License-Identifier: GPL-2.0-only
66# See LICENSES/README.md for more information.
77#
8+ import asyncio
89import json
910import logging
1011import ssl
1112from configparser import SectionProxy
1213from dataclasses import dataclass
1314from 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
2118from smartmeter_datacollector .sinks .data_sink import DataSink
19+ from smartmeter_datacollector .smartmeter .meter_data import MeterDataPoint
2220
2321LOGGER = 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
7478class 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+ )
0 commit comments