mqtt325 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mqtt325-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael Osthege
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mqtt325-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: mqtt325
3
+ Version: 0.1.0
4
+ Summary: Republishes MQTT v3 messages as retained MQTT v5 messages.
5
+ Author-email: Michael Osthege <michael.osthege@outlook.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: paho-mqtt>=2.1.0
17
+ Dynamic: license-file
18
+
19
+ [![PyPI version](https://img.shields.io/pypi/v/mqtt325)](https://pypi.org/project/mqtt325)
20
+ [![pipeline](https://github.com/michaelosthege/mqtt325/workflows/pipeline/badge.svg)](https://github.com/michaelosthege/mqtt325/actions)
21
+
22
+ # MQTT 3 → 5
23
+
24
+ This small Python app provides MQTT message re-routing to create retained messages from devices
25
+ that don't implement MQTT v5 themselves.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install mqtt325
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ Configure these environment variables:
36
+
37
+ * `MQTT_HOST`
38
+ * `MQTT_PORT` (optional)
39
+ * `MQTT_USER` (optional)
40
+ * `MQTT_PASSWORD` (optional)
41
+ * `MQTT_TLS_CHAIN` (optional PEM encoded certificate chain of the broker)
42
+ * `MQTT325_CONFIG_PATH` (optional path to a [`config.py` file](src/mqtt325/config.py) to use instead of the default)
@@ -0,0 +1,24 @@
1
+ [![PyPI version](https://img.shields.io/pypi/v/mqtt325)](https://pypi.org/project/mqtt325)
2
+ [![pipeline](https://github.com/michaelosthege/mqtt325/workflows/pipeline/badge.svg)](https://github.com/michaelosthege/mqtt325/actions)
3
+
4
+ # MQTT 3 → 5
5
+
6
+ This small Python app provides MQTT message re-routing to create retained messages from devices
7
+ that don't implement MQTT v5 themselves.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install mqtt325
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Configure these environment variables:
18
+
19
+ * `MQTT_HOST`
20
+ * `MQTT_PORT` (optional)
21
+ * `MQTT_USER` (optional)
22
+ * `MQTT_PASSWORD` (optional)
23
+ * `MQTT_TLS_CHAIN` (optional PEM encoded certificate chain of the broker)
24
+ * `MQTT325_CONFIG_PATH` (optional path to a [`config.py` file](src/mqtt325/config.py) to use instead of the default)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=62.6"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mqtt325"
7
+ version = "0.1.0"
8
+ description = "Republishes MQTT v3 messages as retained MQTT v5 messages."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ authors = [
13
+ {name = "Michael Osthege", email = "michael.osthege@outlook.com"},
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ ]
23
+
24
+ dependencies = [
25
+ "paho-mqtt>=2.1.0",
26
+ ]
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "build>=1.2.2.post1",
31
+ "pre-commit",
32
+ "pytest>=8.3.5",
33
+ "pytest-cov>=6.0.0",
34
+ "twine>=6.1.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ mqtt325 = "mqtt325.main:run"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ import importlib.metadata
2
+
3
+ __version__ = importlib.metadata.version(__package__ or __name__)
@@ -0,0 +1,13 @@
1
+ from mqtt325.models import AppConfig, Heartbeat, Retainer
2
+
3
+ config = AppConfig(
4
+ availability_topic="availability/mqtt325",
5
+ heartbeat_routes=[
6
+ # Process anything under heartbeat/ into an availability message
7
+ Heartbeat("heartbeat/#", "availability/#"),
8
+ ],
9
+ retain_routes=[
10
+ Retainer("retain/#", "#"),
11
+ ],
12
+ )
13
+ """Programmatic configuration, must be called "config" at module level."""
@@ -0,0 +1,155 @@
1
+ import asyncio
2
+ import importlib
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import paho.mqtt.client as mqtt
8
+ from paho.mqtt.packettypes import PacketTypes
9
+ from paho.mqtt.reasoncodes import ReasonCode
10
+
11
+ from mqtt325.models import AppConfig
12
+
13
+ logger = logging.getLogger(__name__)
14
+ logging.basicConfig(
15
+ level=logging.INFO, format="%(asctime)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
16
+ )
17
+
18
+
19
+ # required
20
+ MQTT_HOST = os.environ["MQTT_HOST"]
21
+ # optional
22
+ MQTT_PORT = int(os.environ.get("MQTT_PORT", 1883))
23
+ MQTT_USER = os.environ.get("MQTT_USER")
24
+ MQTT_PASSWORD = os.environ.get("MQTT_PASSWORD")
25
+ MQTT_TLS_CHAIN = os.environ.get("MQTT_TLS_CHAIN")
26
+ MQTT325_CONFIG_PATH = os.environ.get(
27
+ "MQTT325_CONFIG", str(Path(__file__).parent.absolute() / "config.py")
28
+ )
29
+
30
+
31
+ class Mqtt325App:
32
+ def __init__(self, config: AppConfig):
33
+ self.config = config
34
+
35
+ def on_connect(
36
+ self,
37
+ mqttc: mqtt.Client,
38
+ userdata,
39
+ flags: mqtt.ConnectFlags,
40
+ reason_code: ReasonCode,
41
+ properties,
42
+ ):
43
+ if reason_code == 0:
44
+ logger.info("Connected. Subscribing...")
45
+ for rr in self.config.retain_routes:
46
+ logger.debug(
47
+ "Subscribing (%s) to match (%s) and retain at (%s)",
48
+ rr.input_topic,
49
+ rr.input_pattern,
50
+ rr.output_topic,
51
+ )
52
+ mqttc.subscribe(rr.input_topic)
53
+ for hb in self.config.heartbeat_routes:
54
+ logger.debug(
55
+ "Subscribing (%s) to match (%s) and publish ONLINE/OFFLINE at (%s)",
56
+ hb.input_topic,
57
+ hb.input_pattern,
58
+ hb.output_topic,
59
+ )
60
+ mqttc.subscribe(hb.input_topic)
61
+ logger.info("Subscriptions done.")
62
+ mqttc.publish(self.config.availability_topic, "ONLINE")
63
+ else:
64
+ logger.warning("Connection failed with reason code %s", reason_code)
65
+ return
66
+
67
+ def on_disconnect(
68
+ self,
69
+ client: mqtt.Client,
70
+ userdata,
71
+ flags: mqtt.ConnectFlags,
72
+ reason_code: ReasonCode,
73
+ properties,
74
+ ):
75
+ if reason_code == 0:
76
+ logger.info("Disconnected gracefully.")
77
+ elif reason_code > 0:
78
+ logger.warning("Disonnected unseccessfully. Reason code %s", reason_code)
79
+ return
80
+
81
+ def on_message(self, client: mqtt.Client, userdata, message: mqtt.MQTTMessage):
82
+ top = message.topic
83
+
84
+ logger.info("IN (%s)", top)
85
+ # process retention routes
86
+ for rr in self.config.retain_routes:
87
+ if ptopic := rr.to_output_topic(top):
88
+ logger.info("⏩ (%s) → (%s)", top, ptopic)
89
+ client.publish(ptopic, message.payload, retain=True)
90
+ # process heartbeats into ONLINE messages
91
+ for hb in self.config.heartbeat_routes:
92
+ if ptopic := hb.to_output_topic(top):
93
+ logger.info("💚 (%s) → (%s)", top, ptopic)
94
+ hb.register_beat(top)
95
+ client.publish(ptopic, "ONLINE", retain=True)
96
+ return
97
+
98
+ def process_lost_heartbeats(self, client: mqtt.Client):
99
+ for hb in self.config.heartbeat_routes:
100
+ for src in hb.yield_timed_out():
101
+ # beats are only registered from messages that match
102
+ # this heartbeat route, therefore the publish topic
103
+ # will always be available:
104
+ ptopic = hb.to_output_topic(src)
105
+ assert ptopic is None
106
+ logger.info("💔 (%s) → (%s) OFFLINE", src)
107
+ client.publish(ptopic, "OFFLINE")
108
+ return
109
+
110
+ async def run_async(self):
111
+ logger.info("Connecting MQTT to %s:%s", MQTT_HOST, MQTT_PORT)
112
+ client = mqtt.Client(
113
+ client_id="mqtt-325",
114
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
115
+ )
116
+ if MQTT_USER and MQTT_PASSWORD:
117
+ client.username_pw_set(MQTT_USER, MQTT_PASSWORD)
118
+ if MQTT_TLS_CHAIN:
119
+ client.tls_set(ca_certs=os.environ["MQTT_TLS_CHAIN"])
120
+ client.will_set(self.config.availability_topic, "OFFLINE")
121
+ client.connect(MQTT_HOST, MQTT_PORT, keepalive=5)
122
+ client.on_connect = self.on_connect
123
+ client.on_disconnect = self.on_disconnect
124
+ client.on_message = self.on_message
125
+
126
+ logger.info("Starting MQTT loop")
127
+ client.loop_start()
128
+ while True:
129
+ try:
130
+ self.process_lost_heartbeats(client)
131
+ await asyncio.sleep(1)
132
+ except (asyncio.CancelledError, KeyboardInterrupt):
133
+ logger.info("Shutting down...")
134
+ # Disconnect asking the server to publish the last will message
135
+ client.disconnect(ReasonCode(PacketTypes.DISCONNECT, "Disconnect", 4))
136
+ client.loop_stop()
137
+ break
138
+ except Exception:
139
+ logger.error("Error encountered.", exc_info=True)
140
+ return
141
+
142
+
143
+ def run():
144
+ logger.info("⚙️ Importing config from '%s'", MQTT325_CONFIG_PATH)
145
+ config_module = importlib.machinery.SourceFileLoader(
146
+ "config", MQTT325_CONFIG_PATH
147
+ ).load_module()
148
+ config: AppConfig = getattr(config_module, "config")
149
+ app = Mqtt325App(config)
150
+ asyncio.run(app.run_async())
151
+ logger.info("Exiting.")
152
+
153
+
154
+ if __name__ == "__main__":
155
+ run()
@@ -0,0 +1,80 @@
1
+ """Data models for mqtt325 app configuration."""
2
+
3
+ import re
4
+ import time
5
+ from dataclasses import dataclass, field
6
+ from typing import Generator, Sequence
7
+
8
+
9
+ @dataclass
10
+ class Retainer:
11
+ """Base type of message retention router."""
12
+
13
+ input_topic: str
14
+ """Topic to subscribe.
15
+
16
+ Optionally use `+` where to expect the client ID.
17
+ Optionally append `#` to match any subtopic.
18
+ """
19
+
20
+ output_topic: str
21
+ """Where to publish retained messages."""
22
+
23
+ def __post_init__(self):
24
+ if self.input_topic == self.output_topic:
25
+ raise ValueError("Input and output topic must be different.")
26
+
27
+ @property
28
+ def input_pattern(self) -> str:
29
+ """RegEx pattern to match topics of actual messages."""
30
+ ip = self.input_topic.replace("+", r"(?P<client_id>.+?)")
31
+ ip = ip.replace("#", r"(?P<subtopics>.+?)")
32
+ return ip + "$"
33
+
34
+ def to_output_topic(self, in_topic: str) -> str | None:
35
+ """Determine the publish topic by matching the message topic with the ``input_pattern``."""
36
+ cmatch = re.match(self.input_pattern, in_topic)
37
+ out = None
38
+ if cmatch:
39
+ out = self.output_topic
40
+ gd = cmatch.groupdict()
41
+ if client_id := gd.get("client_id"):
42
+ out = out.replace("+", client_id)
43
+ if subtopics := gd.get("subtopics"):
44
+ out = out.replace("#", subtopics)
45
+ return out
46
+
47
+
48
+ @dataclass
49
+ class Heartbeat(Retainer):
50
+ """Sends retained ONLINE/OFFLINE messages to based on activity in the input topic."""
51
+
52
+ timeout: int = 15
53
+ """Seconds to wait before sending OFFLINE to the output topic."""
54
+
55
+ _beats: dict[str, float] = field(default_factory=dict)
56
+
57
+ def register_beat(self, source_topic: str):
58
+ """Register a heartbeat from a matching source topic."""
59
+ self._beats[source_topic] = time.time()
60
+
61
+ def yield_timed_out(self) -> Generator[str, None, None]:
62
+ """Iterate timed-out source topics, removing them from the cache."""
63
+ buffer = dict(self._beats)
64
+ for src, last_beat in buffer.items():
65
+ if time.time() > last_beat + self.timeout:
66
+ yield src
67
+ self._beats.pop(src)
68
+ return
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class AppConfig:
73
+ availability_topic: str
74
+ """Where the app will publish ONLINE/OFFLINE as retained messages."""
75
+
76
+ heartbeat_routes: Sequence[Heartbeat]
77
+ """List of heartbeat routing configurations."""
78
+
79
+ retain_routes: Sequence[Retainer]
80
+ """Routes for re-publishing with message retention without altering the payload."""
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: mqtt325
3
+ Version: 0.1.0
4
+ Summary: Republishes MQTT v3 messages as retained MQTT v5 messages.
5
+ Author-email: Michael Osthege <michael.osthege@outlook.com>
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Python: >=3.11
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: paho-mqtt>=2.1.0
17
+ Dynamic: license-file
18
+
19
+ [![PyPI version](https://img.shields.io/pypi/v/mqtt325)](https://pypi.org/project/mqtt325)
20
+ [![pipeline](https://github.com/michaelosthege/mqtt325/workflows/pipeline/badge.svg)](https://github.com/michaelosthege/mqtt325/actions)
21
+
22
+ # MQTT 3 → 5
23
+
24
+ This small Python app provides MQTT message re-routing to create retained messages from devices
25
+ that don't implement MQTT v5 themselves.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install mqtt325
31
+ ```
32
+
33
+ ## Configuration
34
+
35
+ Configure these environment variables:
36
+
37
+ * `MQTT_HOST`
38
+ * `MQTT_PORT` (optional)
39
+ * `MQTT_USER` (optional)
40
+ * `MQTT_PASSWORD` (optional)
41
+ * `MQTT_TLS_CHAIN` (optional PEM encoded certificate chain of the broker)
42
+ * `MQTT325_CONFIG_PATH` (optional path to a [`config.py` file](src/mqtt325/config.py) to use instead of the default)
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/mqtt325/__init__.py
5
+ src/mqtt325/config.py
6
+ src/mqtt325/main.py
7
+ src/mqtt325/models.py
8
+ src/mqtt325.egg-info/PKG-INFO
9
+ src/mqtt325.egg-info/SOURCES.txt
10
+ src/mqtt325.egg-info/dependency_links.txt
11
+ src/mqtt325.egg-info/entry_points.txt
12
+ src/mqtt325.egg-info/requires.txt
13
+ src/mqtt325.egg-info/top_level.txt
14
+ tests/test_matching.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mqtt325 = mqtt325.main:run
@@ -0,0 +1 @@
1
+ paho-mqtt>=2.1.0
@@ -0,0 +1 @@
1
+ mqtt325
@@ -0,0 +1,68 @@
1
+ import pytest
2
+
3
+ from mqtt325.models import Retainer
4
+
5
+
6
+ def test_no_identical_in_out_topics():
7
+ with pytest.raises(ValueError, match="must be different"):
8
+ Retainer("foo/+/sub/#", "foo/+/sub/#")
9
+ pass
10
+
11
+
12
+ def test_with_clientid_proxying():
13
+ r = Retainer(
14
+ input_topic="some/+/subtopic",
15
+ output_topic="proxied/some/+/subtopic",
16
+ )
17
+ assert (
18
+ r.to_output_topic("some/client_id/subtopic")
19
+ == "proxied/some/client_id/subtopic"
20
+ )
21
+ pass
22
+
23
+
24
+ def test_withclientid_level_reorder():
25
+ r = Retainer(
26
+ input_topic="some/+/subtopic",
27
+ output_topic="+/some/subtopic",
28
+ )
29
+ assert r.to_output_topic("some/client_id/subtopic") == "client_id/some/subtopic"
30
+ pass
31
+
32
+
33
+ def test_no_clientid_proxying():
34
+ r = Retainer(
35
+ input_topic="some/subtopic",
36
+ output_topic="another/subtopic",
37
+ )
38
+ assert r.to_output_topic("some/subtopic") == "another/subtopic"
39
+ pass
40
+
41
+
42
+ def test_drop_clientid():
43
+ r = Retainer(
44
+ input_topic="some/+/subtopic",
45
+ output_topic="another/subtopic",
46
+ )
47
+ assert r.to_output_topic("some/cid/subtopic") == "another/subtopic"
48
+ pass
49
+
50
+
51
+ def test_wildcard_only():
52
+ r = Retainer(
53
+ input_topic="any/#",
54
+ output_topic="other/#",
55
+ )
56
+ assert r.to_output_topic("any/subtopic") == "other/subtopic"
57
+ assert r.to_output_topic("any/sub/subtopic") == "other/sub/subtopic"
58
+ pass
59
+
60
+
61
+ def test_clientid_and_wildcard():
62
+ r = Retainer(
63
+ input_topic="any/+/#",
64
+ output_topic="other/+/foo/#",
65
+ )
66
+ assert r.to_output_topic("any/cid/subtopic") == "other/cid/foo/subtopic"
67
+ assert r.to_output_topic("any/cid/sub/subtopic") == "other/cid/foo/sub/subtopic"
68
+ pass