franzmq 0.4.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.
- franzmq-0.4.0/LICENSE +21 -0
- franzmq-0.4.0/PKG-INFO +317 -0
- franzmq-0.4.0/README.md +270 -0
- franzmq-0.4.0/pyproject.toml +45 -0
- franzmq-0.4.0/setup.cfg +4 -0
- franzmq-0.4.0/src/franzmq/__init__.py +14 -0
- franzmq-0.4.0/src/franzmq/client.py +758 -0
- franzmq-0.4.0/src/franzmq/data_contracts/__init__.py +45 -0
- franzmq-0.4.0/src/franzmq/data_contracts/base.py +154 -0
- franzmq-0.4.0/src/franzmq/log_handlers.py +69 -0
- franzmq-0.4.0/src/franzmq/message.py +47 -0
- franzmq-0.4.0/src/franzmq/topic.py +245 -0
- franzmq-0.4.0/src/franzmq/topic_base.py +59 -0
- franzmq-0.4.0/src/franzmq.egg-info/PKG-INFO +317 -0
- franzmq-0.4.0/src/franzmq.egg-info/SOURCES.txt +18 -0
- franzmq-0.4.0/src/franzmq.egg-info/dependency_links.txt +1 -0
- franzmq-0.4.0/src/franzmq.egg-info/requires.txt +10 -0
- franzmq-0.4.0/src/franzmq.egg-info/top_level.txt +1 -0
- franzmq-0.4.0/tests/test.py +57 -0
- franzmq-0.4.0/tests/test_logging.py +16 -0
franzmq-0.4.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Alpamayo
|
|
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.
|
franzmq-0.4.0/PKG-INFO
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: franzmq
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Typed MQTT client and topic/payload model for ISA-95-ish messaging.
|
|
5
|
+
Author-email: Alpamayo <info@alpamayo-solutions.com>
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Alpamayo
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/alpamayo-solutions/franzmq
|
|
29
|
+
Project-URL: Issues, https://github.com/alpamayo-solutions/franzmq/issues
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
32
|
+
Classifier: Topic :: Internet
|
|
33
|
+
Classifier: Topic :: System :: Networking
|
|
34
|
+
Requires-Python: >=3.10
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: paho-mqtt>=2.0
|
|
38
|
+
Requires-Dist: python-decouple>=3.8
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: build>=1.2.1; extra == "dev"
|
|
41
|
+
Requires-Dist: twine>=5.0.0; extra == "dev"
|
|
42
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
44
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
45
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# FranzMQ
|
|
49
|
+
|
|
50
|
+
FranzMQ is a structured MQTT communication library for edge and cloud applications. It builds on `paho-mqtt` and introduces typed payloads, hierarchical topics, priority-based callbacks, and a command/acknowledge pattern -- all with optional ISA-95 topic modeling and TLS auto-configuration.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- **Typed payloads** using Python dataclasses with automatic JSON encoding/decoding
|
|
55
|
+
- **Priority-based concurrent callbacks** for message handling
|
|
56
|
+
- **Command/acknowledge pattern** with two-phase handshake for request-response over MQTT
|
|
57
|
+
- **Class-based topic definitions** for type-safe, hierarchical topic construction
|
|
58
|
+
- **ISA-95 topic modeling** for enterprise-ready messaging structures
|
|
59
|
+
- **TLS support** with environment-based auto-configuration
|
|
60
|
+
- **MQTT-based logging** with seamless integration
|
|
61
|
+
|
|
62
|
+
## Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install franzmq
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from franzmq import Client, Topic, Metric
|
|
72
|
+
|
|
73
|
+
client = Client.autocreate_and_connect(client_id="my-client")
|
|
74
|
+
|
|
75
|
+
topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
76
|
+
metric = Metric(value=22.5)
|
|
77
|
+
|
|
78
|
+
client.publish(topic, metric)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Topics
|
|
82
|
+
|
|
83
|
+
FranzMQ topics follow the structure `{prefix}/{version}/{_PayloadType}/{context...}`.
|
|
84
|
+
|
|
85
|
+
### Basic Topic
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from franzmq import Topic, Metric
|
|
89
|
+
|
|
90
|
+
topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
91
|
+
# example/v1/_Metric/sensor/temperature
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### ISA-95 Topic
|
|
95
|
+
|
|
96
|
+
For enterprise-level communication with ISA-95 hierarchy levels:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
from franzmq import Topic, Metric, Isa95Topic, Isa95Fields
|
|
100
|
+
|
|
101
|
+
basic_topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
102
|
+
|
|
103
|
+
isa95_fields = Isa95Fields(
|
|
104
|
+
enterprise="ent1",
|
|
105
|
+
site="s1",
|
|
106
|
+
area="a1",
|
|
107
|
+
production_line="pl1",
|
|
108
|
+
work_cell="wc1",
|
|
109
|
+
origin_id="origin1"
|
|
110
|
+
)
|
|
111
|
+
isa95_topic = Isa95Topic.from_topic(basic_topic, isa95_fields)
|
|
112
|
+
# example/v1-isa95/ent1/s1/a1/pl1/wc1/origin1/_Metric/sensor/temperature
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Typed Payloads
|
|
116
|
+
|
|
117
|
+
All messages use structured dataclasses that encode/decode automatically to/from JSON. The following payload types are included:
|
|
118
|
+
|
|
119
|
+
| Payload | Purpose |
|
|
120
|
+
|---------|---------|
|
|
121
|
+
| `Metric` | Timestamped measurement values |
|
|
122
|
+
| `Log` | Structured log entries (level, message, module, etc.) |
|
|
123
|
+
| `ServiceDetails` | Service registration with type and metadata |
|
|
124
|
+
| `Cmd` | Command with correlation ID and expiration |
|
|
125
|
+
| `Ack` | Acknowledgement with result code and message |
|
|
126
|
+
|
|
127
|
+
Custom payloads extend the `Payload` base class:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from dataclasses import dataclass
|
|
131
|
+
from franzmq import Payload
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class SensorReading(Payload):
|
|
135
|
+
sensor_id: str
|
|
136
|
+
value: float
|
|
137
|
+
unit: str
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Callback System
|
|
141
|
+
|
|
142
|
+
Subscribe to topics and register callbacks with optional priority. Callbacks receive a single `message: Message` argument containing the decoded topic and payload.
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from franzmq import Message
|
|
146
|
+
|
|
147
|
+
def on_metric(message: Message):
|
|
148
|
+
print(f"Received: {message.payload.value} on {message.topic}")
|
|
149
|
+
|
|
150
|
+
client.subscribe(topic, qos=1, callback=on_metric, priority=10)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Callbacks are ordered by descending priority (higher numbers run first). Callbacks with the same priority are executed concurrently in separate threads.
|
|
154
|
+
|
|
155
|
+
## Command/Acknowledge Pattern
|
|
156
|
+
|
|
157
|
+
FranzMQ supports request-response semantics over MQTT using a two-phase acknowledgement flow. This avoids the need for a separate API when you need confirmed command execution.
|
|
158
|
+
|
|
159
|
+
### Flow
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
Sender Receiver
|
|
163
|
+
| |
|
|
164
|
+
|-- Cmd (correlation_id) ------>|
|
|
165
|
+
| | (check expiration)
|
|
166
|
+
|<-- Ack (result_code=-1) ------| (handshake)
|
|
167
|
+
| | (execute callback)
|
|
168
|
+
|<-- Ack (result_code=200) -----| (final result)
|
|
169
|
+
| |
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The handshake ack (`result_code=-1`) confirms the receiver is alive and processing. If the handshake arrives before the command expires, the sender extends its wait up to `max_command_duration`.
|
|
173
|
+
|
|
174
|
+
### Result codes
|
|
175
|
+
|
|
176
|
+
| Code | Meaning |
|
|
177
|
+
|------|---------|
|
|
178
|
+
| -1 | Handshake (receiver acknowledged receipt) |
|
|
179
|
+
| 200 | Success |
|
|
180
|
+
| 400 | Bad request |
|
|
181
|
+
| 500 | Internal error or timeout |
|
|
182
|
+
| 598 | Exception in command callback |
|
|
183
|
+
|
|
184
|
+
### Sending commands
|
|
185
|
+
|
|
186
|
+
`publish_command` subscribes to the ack topic, publishes the command, waits for the two-phase response, and returns the final `Ack`.
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
from franzmq import Client, Topic, Cmd, Ack
|
|
190
|
+
|
|
191
|
+
client = Client.autocreate_and_connect(client_id="sender")
|
|
192
|
+
|
|
193
|
+
cmd_topic = Topic(
|
|
194
|
+
prefix="myproject",
|
|
195
|
+
payload_type=Cmd,
|
|
196
|
+
context=("device1", "settings")
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
ack = client.publish_command(
|
|
200
|
+
topic=cmd_topic,
|
|
201
|
+
command={"enabled": True, "interval_ms": 500},
|
|
202
|
+
validity_duration=30.0,
|
|
203
|
+
max_command_duration=60.0,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if ack.result_code >= 500:
|
|
207
|
+
raise Exception(f"Command failed: {ack.message}")
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Receiving commands
|
|
211
|
+
|
|
212
|
+
`subscribe_to_command` handles expiration checks, handshake acks, and final acks automatically. The callback receives a `Message` and returns a result code.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
from franzmq import Client, Topic, Cmd, Message
|
|
216
|
+
|
|
217
|
+
client = Client.autocreate_and_connect(client_id="receiver")
|
|
218
|
+
|
|
219
|
+
cmd_topic = Topic(
|
|
220
|
+
prefix="myproject",
|
|
221
|
+
payload_type=Cmd,
|
|
222
|
+
context=("device1", "settings")
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
def handle_settings(message: Message) -> int:
|
|
226
|
+
settings = message.payload.command
|
|
227
|
+
apply_settings(settings)
|
|
228
|
+
return 200 # success
|
|
229
|
+
|
|
230
|
+
client.subscribe_to_command(
|
|
231
|
+
topic=cmd_topic,
|
|
232
|
+
callback=handle_settings,
|
|
233
|
+
qos=1,
|
|
234
|
+
)
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The callback can return:
|
|
238
|
+
- `None` -- treated as 200 (success)
|
|
239
|
+
- An `int` result code
|
|
240
|
+
- A `(int, str)` tuple of (result_code, message)
|
|
241
|
+
|
|
242
|
+
Commands for the same topic are executed sequentially via an internal queue.
|
|
243
|
+
|
|
244
|
+
### Custom command payloads
|
|
245
|
+
|
|
246
|
+
Extend `Cmd` for typed command payloads:
|
|
247
|
+
|
|
248
|
+
```python
|
|
249
|
+
from dataclasses import dataclass, field
|
|
250
|
+
from franzmq import Cmd
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class DeviceSettingsCmd(Cmd):
|
|
254
|
+
command: dict = field(default_factory=dict)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Then use `DeviceSettingsCmd` as the topic's `payload_type`.
|
|
258
|
+
|
|
259
|
+
## Class-Based Topic Definitions
|
|
260
|
+
|
|
261
|
+
For projects with many topics, use `TopicBase` and `classproperty` to define hierarchical topic trees:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from franzmq import TopicBase, classproperty, Metric
|
|
265
|
+
from franzmq.data_contracts.base import ServiceDetails
|
|
266
|
+
|
|
267
|
+
class DeviceTopic(TopicBase):
|
|
268
|
+
prefix = "myproject"
|
|
269
|
+
version = "v1"
|
|
270
|
+
context = ("device1",)
|
|
271
|
+
|
|
272
|
+
@classproperty
|
|
273
|
+
def State(cls):
|
|
274
|
+
return cls._topic(["state"], payload_type=ServiceDetails)
|
|
275
|
+
|
|
276
|
+
@classproperty
|
|
277
|
+
def Temperature(cls):
|
|
278
|
+
return cls._topic(["temperature"], payload_type=Metric)
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Access topics as class attributes:
|
|
282
|
+
|
|
283
|
+
```python
|
|
284
|
+
DeviceTopic.State # myproject/v1/_ServiceDetails/device1/state
|
|
285
|
+
DeviceTopic.Temperature # myproject/v1/_Metric/device1/temperature
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Nested hierarchies use `_parent_class_name` and `_prefix` to compose topic paths from parent classes.
|
|
289
|
+
|
|
290
|
+
## Logging over MQTT
|
|
291
|
+
|
|
292
|
+
Enable MQTT-based logging by calling:
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
import logging
|
|
296
|
+
|
|
297
|
+
client.configure_mqtt_logger(level=logging.INFO)
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Auto Configuration via Environment Variables
|
|
301
|
+
|
|
302
|
+
Uses [`python-decouple`](https://github.com/henriquebastos/python-decouple) for environment configuration.
|
|
303
|
+
|
|
304
|
+
| Variable | Required | Default | Description |
|
|
305
|
+
|----------|----------|---------|-------------|
|
|
306
|
+
| `MQTT_IP` | No | `broker` | Broker hostname |
|
|
307
|
+
| `MQTT_PORT` | No | `8883` (TLS) / `1883` (plain) | Broker port |
|
|
308
|
+
| `MQTT_USERNAME` | No | `franz` | Auth username |
|
|
309
|
+
| `MQTT_PASSWORD` | No | `franz` | Auth password |
|
|
310
|
+
| `USE_MQTTS` | No | `True` | Enable TLS |
|
|
311
|
+
| `CA_CERT_FILE` | If TLS | -- | CA certificate path |
|
|
312
|
+
| `TLS_CERT_FILE` | If TLS | -- | Client certificate path |
|
|
313
|
+
| `TLS_KEY_FILE` | If TLS | -- | Client private key path |
|
|
314
|
+
|
|
315
|
+
## License
|
|
316
|
+
|
|
317
|
+
MIT License
|
franzmq-0.4.0/README.md
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# FranzMQ
|
|
2
|
+
|
|
3
|
+
FranzMQ is a structured MQTT communication library for edge and cloud applications. It builds on `paho-mqtt` and introduces typed payloads, hierarchical topics, priority-based callbacks, and a command/acknowledge pattern -- all with optional ISA-95 topic modeling and TLS auto-configuration.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Typed payloads** using Python dataclasses with automatic JSON encoding/decoding
|
|
8
|
+
- **Priority-based concurrent callbacks** for message handling
|
|
9
|
+
- **Command/acknowledge pattern** with two-phase handshake for request-response over MQTT
|
|
10
|
+
- **Class-based topic definitions** for type-safe, hierarchical topic construction
|
|
11
|
+
- **ISA-95 topic modeling** for enterprise-ready messaging structures
|
|
12
|
+
- **TLS support** with environment-based auto-configuration
|
|
13
|
+
- **MQTT-based logging** with seamless integration
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install franzmq
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from franzmq import Client, Topic, Metric
|
|
25
|
+
|
|
26
|
+
client = Client.autocreate_and_connect(client_id="my-client")
|
|
27
|
+
|
|
28
|
+
topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
29
|
+
metric = Metric(value=22.5)
|
|
30
|
+
|
|
31
|
+
client.publish(topic, metric)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Topics
|
|
35
|
+
|
|
36
|
+
FranzMQ topics follow the structure `{prefix}/{version}/{_PayloadType}/{context...}`.
|
|
37
|
+
|
|
38
|
+
### Basic Topic
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
from franzmq import Topic, Metric
|
|
42
|
+
|
|
43
|
+
topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
44
|
+
# example/v1/_Metric/sensor/temperature
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### ISA-95 Topic
|
|
48
|
+
|
|
49
|
+
For enterprise-level communication with ISA-95 hierarchy levels:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from franzmq import Topic, Metric, Isa95Topic, Isa95Fields
|
|
53
|
+
|
|
54
|
+
basic_topic = Topic(payload_type=Metric, context=("sensor", "temperature"))
|
|
55
|
+
|
|
56
|
+
isa95_fields = Isa95Fields(
|
|
57
|
+
enterprise="ent1",
|
|
58
|
+
site="s1",
|
|
59
|
+
area="a1",
|
|
60
|
+
production_line="pl1",
|
|
61
|
+
work_cell="wc1",
|
|
62
|
+
origin_id="origin1"
|
|
63
|
+
)
|
|
64
|
+
isa95_topic = Isa95Topic.from_topic(basic_topic, isa95_fields)
|
|
65
|
+
# example/v1-isa95/ent1/s1/a1/pl1/wc1/origin1/_Metric/sensor/temperature
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Typed Payloads
|
|
69
|
+
|
|
70
|
+
All messages use structured dataclasses that encode/decode automatically to/from JSON. The following payload types are included:
|
|
71
|
+
|
|
72
|
+
| Payload | Purpose |
|
|
73
|
+
|---------|---------|
|
|
74
|
+
| `Metric` | Timestamped measurement values |
|
|
75
|
+
| `Log` | Structured log entries (level, message, module, etc.) |
|
|
76
|
+
| `ServiceDetails` | Service registration with type and metadata |
|
|
77
|
+
| `Cmd` | Command with correlation ID and expiration |
|
|
78
|
+
| `Ack` | Acknowledgement with result code and message |
|
|
79
|
+
|
|
80
|
+
Custom payloads extend the `Payload` base class:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from dataclasses import dataclass
|
|
84
|
+
from franzmq import Payload
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class SensorReading(Payload):
|
|
88
|
+
sensor_id: str
|
|
89
|
+
value: float
|
|
90
|
+
unit: str
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Callback System
|
|
94
|
+
|
|
95
|
+
Subscribe to topics and register callbacks with optional priority. Callbacks receive a single `message: Message` argument containing the decoded topic and payload.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from franzmq import Message
|
|
99
|
+
|
|
100
|
+
def on_metric(message: Message):
|
|
101
|
+
print(f"Received: {message.payload.value} on {message.topic}")
|
|
102
|
+
|
|
103
|
+
client.subscribe(topic, qos=1, callback=on_metric, priority=10)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Callbacks are ordered by descending priority (higher numbers run first). Callbacks with the same priority are executed concurrently in separate threads.
|
|
107
|
+
|
|
108
|
+
## Command/Acknowledge Pattern
|
|
109
|
+
|
|
110
|
+
FranzMQ supports request-response semantics over MQTT using a two-phase acknowledgement flow. This avoids the need for a separate API when you need confirmed command execution.
|
|
111
|
+
|
|
112
|
+
### Flow
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
Sender Receiver
|
|
116
|
+
| |
|
|
117
|
+
|-- Cmd (correlation_id) ------>|
|
|
118
|
+
| | (check expiration)
|
|
119
|
+
|<-- Ack (result_code=-1) ------| (handshake)
|
|
120
|
+
| | (execute callback)
|
|
121
|
+
|<-- Ack (result_code=200) -----| (final result)
|
|
122
|
+
| |
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The handshake ack (`result_code=-1`) confirms the receiver is alive and processing. If the handshake arrives before the command expires, the sender extends its wait up to `max_command_duration`.
|
|
126
|
+
|
|
127
|
+
### Result codes
|
|
128
|
+
|
|
129
|
+
| Code | Meaning |
|
|
130
|
+
|------|---------|
|
|
131
|
+
| -1 | Handshake (receiver acknowledged receipt) |
|
|
132
|
+
| 200 | Success |
|
|
133
|
+
| 400 | Bad request |
|
|
134
|
+
| 500 | Internal error or timeout |
|
|
135
|
+
| 598 | Exception in command callback |
|
|
136
|
+
|
|
137
|
+
### Sending commands
|
|
138
|
+
|
|
139
|
+
`publish_command` subscribes to the ack topic, publishes the command, waits for the two-phase response, and returns the final `Ack`.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from franzmq import Client, Topic, Cmd, Ack
|
|
143
|
+
|
|
144
|
+
client = Client.autocreate_and_connect(client_id="sender")
|
|
145
|
+
|
|
146
|
+
cmd_topic = Topic(
|
|
147
|
+
prefix="myproject",
|
|
148
|
+
payload_type=Cmd,
|
|
149
|
+
context=("device1", "settings")
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
ack = client.publish_command(
|
|
153
|
+
topic=cmd_topic,
|
|
154
|
+
command={"enabled": True, "interval_ms": 500},
|
|
155
|
+
validity_duration=30.0,
|
|
156
|
+
max_command_duration=60.0,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if ack.result_code >= 500:
|
|
160
|
+
raise Exception(f"Command failed: {ack.message}")
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Receiving commands
|
|
164
|
+
|
|
165
|
+
`subscribe_to_command` handles expiration checks, handshake acks, and final acks automatically. The callback receives a `Message` and returns a result code.
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
from franzmq import Client, Topic, Cmd, Message
|
|
169
|
+
|
|
170
|
+
client = Client.autocreate_and_connect(client_id="receiver")
|
|
171
|
+
|
|
172
|
+
cmd_topic = Topic(
|
|
173
|
+
prefix="myproject",
|
|
174
|
+
payload_type=Cmd,
|
|
175
|
+
context=("device1", "settings")
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def handle_settings(message: Message) -> int:
|
|
179
|
+
settings = message.payload.command
|
|
180
|
+
apply_settings(settings)
|
|
181
|
+
return 200 # success
|
|
182
|
+
|
|
183
|
+
client.subscribe_to_command(
|
|
184
|
+
topic=cmd_topic,
|
|
185
|
+
callback=handle_settings,
|
|
186
|
+
qos=1,
|
|
187
|
+
)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The callback can return:
|
|
191
|
+
- `None` -- treated as 200 (success)
|
|
192
|
+
- An `int` result code
|
|
193
|
+
- A `(int, str)` tuple of (result_code, message)
|
|
194
|
+
|
|
195
|
+
Commands for the same topic are executed sequentially via an internal queue.
|
|
196
|
+
|
|
197
|
+
### Custom command payloads
|
|
198
|
+
|
|
199
|
+
Extend `Cmd` for typed command payloads:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from dataclasses import dataclass, field
|
|
203
|
+
from franzmq import Cmd
|
|
204
|
+
|
|
205
|
+
@dataclass
|
|
206
|
+
class DeviceSettingsCmd(Cmd):
|
|
207
|
+
command: dict = field(default_factory=dict)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Then use `DeviceSettingsCmd` as the topic's `payload_type`.
|
|
211
|
+
|
|
212
|
+
## Class-Based Topic Definitions
|
|
213
|
+
|
|
214
|
+
For projects with many topics, use `TopicBase` and `classproperty` to define hierarchical topic trees:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from franzmq import TopicBase, classproperty, Metric
|
|
218
|
+
from franzmq.data_contracts.base import ServiceDetails
|
|
219
|
+
|
|
220
|
+
class DeviceTopic(TopicBase):
|
|
221
|
+
prefix = "myproject"
|
|
222
|
+
version = "v1"
|
|
223
|
+
context = ("device1",)
|
|
224
|
+
|
|
225
|
+
@classproperty
|
|
226
|
+
def State(cls):
|
|
227
|
+
return cls._topic(["state"], payload_type=ServiceDetails)
|
|
228
|
+
|
|
229
|
+
@classproperty
|
|
230
|
+
def Temperature(cls):
|
|
231
|
+
return cls._topic(["temperature"], payload_type=Metric)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Access topics as class attributes:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
DeviceTopic.State # myproject/v1/_ServiceDetails/device1/state
|
|
238
|
+
DeviceTopic.Temperature # myproject/v1/_Metric/device1/temperature
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Nested hierarchies use `_parent_class_name` and `_prefix` to compose topic paths from parent classes.
|
|
242
|
+
|
|
243
|
+
## Logging over MQTT
|
|
244
|
+
|
|
245
|
+
Enable MQTT-based logging by calling:
|
|
246
|
+
|
|
247
|
+
```python
|
|
248
|
+
import logging
|
|
249
|
+
|
|
250
|
+
client.configure_mqtt_logger(level=logging.INFO)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Auto Configuration via Environment Variables
|
|
254
|
+
|
|
255
|
+
Uses [`python-decouple`](https://github.com/henriquebastos/python-decouple) for environment configuration.
|
|
256
|
+
|
|
257
|
+
| Variable | Required | Default | Description |
|
|
258
|
+
|----------|----------|---------|-------------|
|
|
259
|
+
| `MQTT_IP` | No | `broker` | Broker hostname |
|
|
260
|
+
| `MQTT_PORT` | No | `8883` (TLS) / `1883` (plain) | Broker port |
|
|
261
|
+
| `MQTT_USERNAME` | No | `franz` | Auth username |
|
|
262
|
+
| `MQTT_PASSWORD` | No | `franz` | Auth password |
|
|
263
|
+
| `USE_MQTTS` | No | `True` | Enable TLS |
|
|
264
|
+
| `CA_CERT_FILE` | If TLS | -- | CA certificate path |
|
|
265
|
+
| `TLS_CERT_FILE` | If TLS | -- | Client certificate path |
|
|
266
|
+
| `TLS_KEY_FILE` | If TLS | -- | Client private key path |
|
|
267
|
+
|
|
268
|
+
## License
|
|
269
|
+
|
|
270
|
+
MIT License
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "franzmq"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Typed MQTT client and topic/payload model for ISA-95-ish messaging."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "Alpamayo", email = "info@alpamayo-solutions.com" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"paho-mqtt>=2.0",
|
|
15
|
+
"python-decouple>=3.8",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# These show up on PyPI
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Topic :: Internet",
|
|
23
|
+
"Topic :: System :: Networking",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/alpamayo-solutions/franzmq"
|
|
28
|
+
Issues = "https://github.com/alpamayo-solutions/franzmq/issues"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
namespaces = true
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
dev = [
|
|
39
|
+
"build>=1.2.1",
|
|
40
|
+
"twine>=5.0.0",
|
|
41
|
+
"pytest>=8.0",
|
|
42
|
+
"ruff>=0.5.0",
|
|
43
|
+
"black>=24.0",
|
|
44
|
+
"mypy>=1.10",
|
|
45
|
+
]
|
franzmq-0.4.0/setup.cfg
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .client import Client
|
|
2
|
+
from .topic import Topic, Isa95Fields, Isa95Topic
|
|
3
|
+
from .topic_base import TopicBase, classproperty
|
|
4
|
+
from .message import Message
|
|
5
|
+
from .data_contracts.base import Payload, Metric, Log, Cmd, Ack
|
|
6
|
+
from .log_handlers import configure_logging
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Payload", "Message", "Topic", "Client",
|
|
10
|
+
"Isa95Fields", "Isa95Topic",
|
|
11
|
+
"Metric", "Log", "Cmd", "Ack",
|
|
12
|
+
"TopicBase", "classproperty",
|
|
13
|
+
"configure_logging",
|
|
14
|
+
]
|