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 +21 -0
- mqtt325-0.1.0/PKG-INFO +42 -0
- mqtt325-0.1.0/README.md +24 -0
- mqtt325-0.1.0/pyproject.toml +38 -0
- mqtt325-0.1.0/setup.cfg +4 -0
- mqtt325-0.1.0/src/mqtt325/__init__.py +3 -0
- mqtt325-0.1.0/src/mqtt325/config.py +13 -0
- mqtt325-0.1.0/src/mqtt325/main.py +155 -0
- mqtt325-0.1.0/src/mqtt325/models.py +80 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/PKG-INFO +42 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/SOURCES.txt +14 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/dependency_links.txt +1 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/entry_points.txt +2 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/requires.txt +1 -0
- mqtt325-0.1.0/src/mqtt325.egg-info/top_level.txt +1 -0
- mqtt325-0.1.0/tests/test_matching.py +68 -0
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
|
+
[](https://pypi.org/project/mqtt325)
|
|
20
|
+
[](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)
|
mqtt325-0.1.0/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[](https://pypi.org/project/mqtt325)
|
|
2
|
+
[](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"
|
mqtt325-0.1.0/setup.cfg
ADDED
|
@@ -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
|
+
[](https://pypi.org/project/mqtt325)
|
|
20
|
+
[](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 @@
|
|
|
1
|
+
|
|
@@ -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
|