connectmq 1.0.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ConnectMQ
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.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.5
2
+ Name: connectmq
3
+ Version: 1.0.0
4
+ Summary: Publish and subscribe on the free public MQTT broker broker.connectmq.com in one line. Preconfigured paho-mqtt client and CLI for TCP, TLS, WebSocket and secure WebSocket.
5
+ Project-URL: Homepage, https://connectmq.com
6
+ Project-URL: Documentation, https://connectmq.com/blog/free-public-mqtt-broker
7
+ Author: ConnectMQ Team
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: connectmq,free-mqtt-broker,iot,mqtt,mqtt-broker,mqtt-client,paho-mqtt,public-mqtt-broker,pubsub
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Communications
16
+ Classifier: Topic :: Internet
17
+ Classifier: Topic :: System :: Networking
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Requires-Dist: paho-mqtt<3,>=2.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # connectmq
24
+
25
+ Publish and subscribe on the free public MQTT broker **broker.connectmq.com** in one line, with no account needed.
26
+ The package is a preconfigured [paho-mqtt](https://pypi.org/project/paho-mqtt/) client and CLI for TCP, TLS, WebSocket and secure WebSocket.
27
+ It also works with private brokers created on [connectmq.com](https://connectmq.com).
28
+
29
+ ```bash
30
+ pip install connectmq
31
+ connectmq test
32
+ # OK mqtts://broker.connectmq.com:8883 connect 220 ms, round-trip 46 ms
33
+ ```
34
+
35
+ Requires Python 3.9 or later.
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ import connectmq
41
+
42
+ prefix = connectmq.topic_prefix() # e.g. "connectmq/demo/7f3a91"
43
+
44
+ # Publish (dicts and lists are sent as JSON)
45
+ connectmq.publish(f"{prefix}/temperature", {"value": 21.5}, retain=True)
46
+
47
+ # Subscribe: iterate over incoming messages
48
+ for msg in connectmq.subscribe(f"{prefix}/#", count=1, timeout=10):
49
+ print(msg.topic, msg.json())
50
+ ```
51
+
52
+ The public area is shared, so use a random prefix to keep your topics apart from other testers.
53
+
54
+ ## Reuse one connection
55
+
56
+ ```python
57
+ import connectmq
58
+
59
+ with connectmq.Client(transport="tls") as client:
60
+ # Callback style: returns a function that unsubscribes
61
+ stop = client.subscribe("connectmq/demo/7f3a91/#", lambda m: print(m.topic, m.payload))
62
+
63
+ for i in range(10):
64
+ client.publish("connectmq/demo/7f3a91/counter", i, qos=1)
65
+
66
+ reply = client.once("connectmq/demo/7f3a91/reply", timeout=5) # raises TimeoutError
67
+ stop()
68
+ ```
69
+
70
+ ## API
71
+
72
+ | Function | Description |
73
+ |---|---|
74
+ | `publish(topic, payload, qos=0, retain=False, **options)` | Publish one message with a short-lived connection |
75
+ | `subscribe(topics, qos=0, count=0, timeout=None, **options)` | Iterator over messages; stops after `count` messages or `timeout` seconds of silence |
76
+ | `once(topic, timeout=30, **options)` | Next message on a topic filter; raises `TimeoutError` |
77
+ | `connect(**options)` / `Client(**options)` | Connected client; use as a context manager |
78
+ | `test(transport="tls", **options)` | Connects, publishes, receives; returns `TestResult(ok, url, connect_ms, round_trip_ms, error)` |
79
+ | `broker_info()` | Endpoints and limits of the public broker |
80
+ | `topic_prefix(root="connectmq/demo")` | Random topic prefix |
81
+ | `broker_url(transport, host, port)` | URL for `tcp`, `tls`, `ws` or `wss` |
82
+ | `topic_matches(filter, topic)` | Wildcard matching for `+`, `#` and `$share/...` |
83
+
84
+ `Client` methods: `publish()`, `subscribe(topics, callback, qos)`, `messages(topics, qos, count, timeout)`, `once()` and `close()`.
85
+ The underlying paho client is available as `client.mqtt`.
86
+
87
+ Callbacks are registered before the broker acknowledges a subscription, so retained messages are never missed.
88
+
89
+ `Message` has `topic`, `payload` (UTF-8 string), `raw` (bytes), `qos`, `retain`, `received` (datetime) and `json()`.
90
+
91
+ ### Options
92
+
93
+ | Option | Default | Notes |
94
+ |---|---|---|
95
+ | `transport` | `"tls"` | `tcp` 1883, `tls` 8883, `ws` 8083, `wss` 8084 (`/mqtt`) |
96
+ | `host` | `"broker.connectmq.com"` | Any MQTT broker |
97
+ | `port` | Standard port | |
98
+ | `client_id` | `py-` plus a random suffix | Must be unique and non-empty |
99
+ | `username`, `password` | `None` | Not needed on the public broker |
100
+ | `protocol` | `5` | `5` for MQTT 5.0, `4` for MQTT 3.1.1 |
101
+ | `keepalive`, `timeout` | `60`, `10.0` | Seconds |
102
+
103
+ ## Examples
104
+
105
+ **Send sensor readings**
106
+
107
+ ```python
108
+ import random, time, connectmq
109
+
110
+ with connectmq.Client() as client:
111
+ while True:
112
+ client.publish("connectmq/demo/7f3a91/sensor", {"temperature": round(random.uniform(20, 25), 1)})
113
+ time.sleep(5)
114
+ ```
115
+
116
+ **Log messages to CSV**
117
+
118
+ ```python
119
+ import csv, connectmq
120
+
121
+ with open("mqtt-log.csv", "a", newline="") as f:
122
+ writer = csv.writer(f)
123
+ for msg in connectmq.subscribe("connectmq/demo/7f3a91/#"):
124
+ writer.writerow([msg.received.isoformat(), msg.topic, msg.payload])
125
+ ```
126
+
127
+ **Private ConnectMQ broker**
128
+
129
+ ```python
130
+ import os, connectmq
131
+
132
+ client = connectmq.connect(
133
+ host="YOUR_BROKER_HOST",
134
+ transport="tls",
135
+ username=os.environ["MQTT_USERNAME"],
136
+ password=os.environ["MQTT_PASSWORD"],
137
+ )
138
+ ```
139
+
140
+ ## CLI
141
+
142
+ ```bash
143
+ connectmq test # connectivity and round-trip
144
+ connectmq info # endpoints and limits
145
+ connectmq prefix # random topic prefix
146
+ connectmq sub "connectmq/demo/7f3a91/#"
147
+ connectmq pub connectmq/demo/7f3a91/temp '{"value":21.5}' -q 1 -r
148
+ echo hello | connectmq pub connectmq/demo/7f3a91/msg
149
+ python -m connectmq test # if the script folder is not on PATH
150
+ ```
151
+
152
+ | Option | Description |
153
+ |---|---|
154
+ | `-t, --transport` | `tcp`, `tls` (default), `ws`, `wss` |
155
+ | `-H, --host`, `-P, --port` | Broker host and port |
156
+ | `-q, --qos` | 0, 1 or 2 |
157
+ | `-r, --retain` | Retained message (`pub`) |
158
+ | `-n, --count` | Exit after n messages (`sub`) |
159
+ | `-j, --json` | JSON line output (`sub`) |
160
+ | `-u, --username`, `-p, --password` | Credentials for private brokers |
161
+
162
+ ## Public broker
163
+
164
+ | Protocol | URL |
165
+ |---|---|
166
+ | MQTT (TCP) | `mqtt://broker.connectmq.com:1883` |
167
+ | MQTT over TLS | `mqtts://broker.connectmq.com:8883` |
168
+ | WebSocket | `ws://broker.connectmq.com:8083/mqtt` |
169
+ | Secure WebSocket | `wss://broker.connectmq.com:8084/mqtt` |
170
+
171
+ Access is anonymous, but the client ID must not be empty. Limits: 64 KB per message, 10 messages per second and 10 subscriptions per client, topics up to 8 levels deep, and sessions expire after at most 30 minutes.
172
+
173
+ > **Everything on the public broker is public.** Any client can subscribe to `#`.
174
+ > Use the broker only for testing, and always under a random topic prefix.
175
+ > For production, create a private broker with its own credentials at [connectmq.com](https://connectmq.com).
176
+
177
+ More: [Free Public MQTT Broker for Testing](https://connectmq.com/blog/free-public-mqtt-broker).
178
+ Also available for Node.js (`@connectmq/mqtt`), .NET (`ConnectMQ.Mqtt`), Node-RED (`node-red-contrib-connectmq`) and PowerShell (`MQTTBroker`).
179
+
180
+ ## License
181
+
182
+ MIT
@@ -0,0 +1,160 @@
1
+ # connectmq
2
+
3
+ Publish and subscribe on the free public MQTT broker **broker.connectmq.com** in one line, with no account needed.
4
+ The package is a preconfigured [paho-mqtt](https://pypi.org/project/paho-mqtt/) client and CLI for TCP, TLS, WebSocket and secure WebSocket.
5
+ It also works with private brokers created on [connectmq.com](https://connectmq.com).
6
+
7
+ ```bash
8
+ pip install connectmq
9
+ connectmq test
10
+ # OK mqtts://broker.connectmq.com:8883 connect 220 ms, round-trip 46 ms
11
+ ```
12
+
13
+ Requires Python 3.9 or later.
14
+
15
+ ## Quick start
16
+
17
+ ```python
18
+ import connectmq
19
+
20
+ prefix = connectmq.topic_prefix() # e.g. "connectmq/demo/7f3a91"
21
+
22
+ # Publish (dicts and lists are sent as JSON)
23
+ connectmq.publish(f"{prefix}/temperature", {"value": 21.5}, retain=True)
24
+
25
+ # Subscribe: iterate over incoming messages
26
+ for msg in connectmq.subscribe(f"{prefix}/#", count=1, timeout=10):
27
+ print(msg.topic, msg.json())
28
+ ```
29
+
30
+ The public area is shared, so use a random prefix to keep your topics apart from other testers.
31
+
32
+ ## Reuse one connection
33
+
34
+ ```python
35
+ import connectmq
36
+
37
+ with connectmq.Client(transport="tls") as client:
38
+ # Callback style: returns a function that unsubscribes
39
+ stop = client.subscribe("connectmq/demo/7f3a91/#", lambda m: print(m.topic, m.payload))
40
+
41
+ for i in range(10):
42
+ client.publish("connectmq/demo/7f3a91/counter", i, qos=1)
43
+
44
+ reply = client.once("connectmq/demo/7f3a91/reply", timeout=5) # raises TimeoutError
45
+ stop()
46
+ ```
47
+
48
+ ## API
49
+
50
+ | Function | Description |
51
+ |---|---|
52
+ | `publish(topic, payload, qos=0, retain=False, **options)` | Publish one message with a short-lived connection |
53
+ | `subscribe(topics, qos=0, count=0, timeout=None, **options)` | Iterator over messages; stops after `count` messages or `timeout` seconds of silence |
54
+ | `once(topic, timeout=30, **options)` | Next message on a topic filter; raises `TimeoutError` |
55
+ | `connect(**options)` / `Client(**options)` | Connected client; use as a context manager |
56
+ | `test(transport="tls", **options)` | Connects, publishes, receives; returns `TestResult(ok, url, connect_ms, round_trip_ms, error)` |
57
+ | `broker_info()` | Endpoints and limits of the public broker |
58
+ | `topic_prefix(root="connectmq/demo")` | Random topic prefix |
59
+ | `broker_url(transport, host, port)` | URL for `tcp`, `tls`, `ws` or `wss` |
60
+ | `topic_matches(filter, topic)` | Wildcard matching for `+`, `#` and `$share/...` |
61
+
62
+ `Client` methods: `publish()`, `subscribe(topics, callback, qos)`, `messages(topics, qos, count, timeout)`, `once()` and `close()`.
63
+ The underlying paho client is available as `client.mqtt`.
64
+
65
+ Callbacks are registered before the broker acknowledges a subscription, so retained messages are never missed.
66
+
67
+ `Message` has `topic`, `payload` (UTF-8 string), `raw` (bytes), `qos`, `retain`, `received` (datetime) and `json()`.
68
+
69
+ ### Options
70
+
71
+ | Option | Default | Notes |
72
+ |---|---|---|
73
+ | `transport` | `"tls"` | `tcp` 1883, `tls` 8883, `ws` 8083, `wss` 8084 (`/mqtt`) |
74
+ | `host` | `"broker.connectmq.com"` | Any MQTT broker |
75
+ | `port` | Standard port | |
76
+ | `client_id` | `py-` plus a random suffix | Must be unique and non-empty |
77
+ | `username`, `password` | `None` | Not needed on the public broker |
78
+ | `protocol` | `5` | `5` for MQTT 5.0, `4` for MQTT 3.1.1 |
79
+ | `keepalive`, `timeout` | `60`, `10.0` | Seconds |
80
+
81
+ ## Examples
82
+
83
+ **Send sensor readings**
84
+
85
+ ```python
86
+ import random, time, connectmq
87
+
88
+ with connectmq.Client() as client:
89
+ while True:
90
+ client.publish("connectmq/demo/7f3a91/sensor", {"temperature": round(random.uniform(20, 25), 1)})
91
+ time.sleep(5)
92
+ ```
93
+
94
+ **Log messages to CSV**
95
+
96
+ ```python
97
+ import csv, connectmq
98
+
99
+ with open("mqtt-log.csv", "a", newline="") as f:
100
+ writer = csv.writer(f)
101
+ for msg in connectmq.subscribe("connectmq/demo/7f3a91/#"):
102
+ writer.writerow([msg.received.isoformat(), msg.topic, msg.payload])
103
+ ```
104
+
105
+ **Private ConnectMQ broker**
106
+
107
+ ```python
108
+ import os, connectmq
109
+
110
+ client = connectmq.connect(
111
+ host="YOUR_BROKER_HOST",
112
+ transport="tls",
113
+ username=os.environ["MQTT_USERNAME"],
114
+ password=os.environ["MQTT_PASSWORD"],
115
+ )
116
+ ```
117
+
118
+ ## CLI
119
+
120
+ ```bash
121
+ connectmq test # connectivity and round-trip
122
+ connectmq info # endpoints and limits
123
+ connectmq prefix # random topic prefix
124
+ connectmq sub "connectmq/demo/7f3a91/#"
125
+ connectmq pub connectmq/demo/7f3a91/temp '{"value":21.5}' -q 1 -r
126
+ echo hello | connectmq pub connectmq/demo/7f3a91/msg
127
+ python -m connectmq test # if the script folder is not on PATH
128
+ ```
129
+
130
+ | Option | Description |
131
+ |---|---|
132
+ | `-t, --transport` | `tcp`, `tls` (default), `ws`, `wss` |
133
+ | `-H, --host`, `-P, --port` | Broker host and port |
134
+ | `-q, --qos` | 0, 1 or 2 |
135
+ | `-r, --retain` | Retained message (`pub`) |
136
+ | `-n, --count` | Exit after n messages (`sub`) |
137
+ | `-j, --json` | JSON line output (`sub`) |
138
+ | `-u, --username`, `-p, --password` | Credentials for private brokers |
139
+
140
+ ## Public broker
141
+
142
+ | Protocol | URL |
143
+ |---|---|
144
+ | MQTT (TCP) | `mqtt://broker.connectmq.com:1883` |
145
+ | MQTT over TLS | `mqtts://broker.connectmq.com:8883` |
146
+ | WebSocket | `ws://broker.connectmq.com:8083/mqtt` |
147
+ | Secure WebSocket | `wss://broker.connectmq.com:8084/mqtt` |
148
+
149
+ Access is anonymous, but the client ID must not be empty. Limits: 64 KB per message, 10 messages per second and 10 subscriptions per client, topics up to 8 levels deep, and sessions expire after at most 30 minutes.
150
+
151
+ > **Everything on the public broker is public.** Any client can subscribe to `#`.
152
+ > Use the broker only for testing, and always under a random topic prefix.
153
+ > For production, create a private broker with its own credentials at [connectmq.com](https://connectmq.com).
154
+
155
+ More: [Free Public MQTT Broker for Testing](https://connectmq.com/blog/free-public-mqtt-broker).
156
+ Also available for Node.js (`@connectmq/mqtt`), .NET (`ConnectMQ.Mqtt`), Node-RED (`node-red-contrib-connectmq`) and PowerShell (`MQTTBroker`).
157
+
158
+ ## License
159
+
160
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "connectmq"
7
+ version = "1.0.0"
8
+ description = "Publish and subscribe on the free public MQTT broker broker.connectmq.com in one line. Preconfigured paho-mqtt client and CLI for TCP, TLS, WebSocket and secure WebSocket."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [{ name = "ConnectMQ Team" }]
14
+ keywords = ["mqtt", "mqtt-broker", "public-mqtt-broker", "free-mqtt-broker", "mqtt-client", "iot", "pubsub", "paho-mqtt", "connectmq"]
15
+ classifiers = [
16
+ "Development Status :: 5 - Production/Stable",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Communications",
21
+ "Topic :: Internet",
22
+ "Topic :: System :: Networking",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["paho-mqtt>=2.0,<3"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://connectmq.com"
29
+ Documentation = "https://connectmq.com/blog/free-public-mqtt-broker"
30
+
31
+ [project.scripts]
32
+ connectmq = "connectmq.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/connectmq"]
@@ -0,0 +1,43 @@
1
+ """Publish and subscribe on the free public MQTT broker broker.connectmq.com.
2
+
3
+ >>> import connectmq
4
+ >>> connectmq.publish("connectmq/demo/7f3a91/temp", {"value": 21.5})
5
+ >>> for msg in connectmq.subscribe("connectmq/demo/7f3a91/#", count=1):
6
+ ... print(msg.topic, msg.payload)
7
+ """
8
+
9
+ from .client import (
10
+ BROKER_HOST,
11
+ ENDPOINTS,
12
+ Client,
13
+ Message,
14
+ TestResult,
15
+ broker_info,
16
+ broker_url,
17
+ connect,
18
+ once,
19
+ publish,
20
+ subscribe,
21
+ test,
22
+ topic_matches,
23
+ topic_prefix,
24
+ )
25
+
26
+ __version__ = "1.0.0"
27
+
28
+ __all__ = [
29
+ "BROKER_HOST",
30
+ "ENDPOINTS",
31
+ "Client",
32
+ "Message",
33
+ "TestResult",
34
+ "broker_info",
35
+ "broker_url",
36
+ "connect",
37
+ "once",
38
+ "publish",
39
+ "subscribe",
40
+ "test",
41
+ "topic_matches",
42
+ "topic_prefix",
43
+ ]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,97 @@
1
+ """Command-line interface: ``connectmq test | info | prefix | sub | pub``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import List, Optional
9
+
10
+ from . import Client, broker_info, test, topic_prefix
11
+
12
+ EPILOG = """examples:
13
+ connectmq test
14
+ connectmq sub "connectmq/demo/7f3a91/#"
15
+ connectmq pub connectmq/demo/7f3a91/temp '{"value":21.5}' -q 1
16
+
17
+ Public area: anyone can read your messages. Private brokers: https://connectmq.com"""
18
+
19
+
20
+ def _parser() -> argparse.ArgumentParser:
21
+ connection = argparse.ArgumentParser(add_help=False)
22
+ connection.add_argument("-t", "--transport", choices=["tcp", "tls", "ws", "wss"], default="tls", help="default: tls")
23
+ connection.add_argument("-H", "--host", default="broker.connectmq.com", help="default: broker.connectmq.com")
24
+ connection.add_argument("-P", "--port", type=int, help="override the transport's standard port")
25
+ connection.add_argument("-u", "--username", help="for private brokers")
26
+ connection.add_argument("-p", "--password", help="for private brokers")
27
+ connection.add_argument("-q", "--qos", type=int, choices=[0, 1, 2], default=0)
28
+
29
+ parser = argparse.ArgumentParser(
30
+ prog="connectmq",
31
+ description="MQTT client for the free public broker broker.connectmq.com",
32
+ epilog=EPILOG,
33
+ formatter_class=argparse.RawDescriptionHelpFormatter,
34
+ )
35
+ commands = parser.add_subparsers(dest="command")
36
+ commands.add_parser("test", parents=[connection], help="check connectivity and message round-trip (default)")
37
+ commands.add_parser("info", help="show public broker endpoints and limits")
38
+ commands.add_parser("prefix", help="print a random topic prefix")
39
+ sub = commands.add_parser("sub", parents=[connection], help="subscribe and print messages")
40
+ sub.add_argument("topics", nargs="+")
41
+ sub.add_argument("-n", "--count", type=int, default=0, help="exit after n messages")
42
+ sub.add_argument("-j", "--json", action="store_true", help="print JSON lines")
43
+ pub = commands.add_parser("pub", parents=[connection], help="publish a message (stdin if omitted)")
44
+ pub.add_argument("topic")
45
+ pub.add_argument("message", nargs="?")
46
+ pub.add_argument("-r", "--retain", action="store_true")
47
+ return parser
48
+
49
+
50
+ def main(argv: Optional[List[str]] = None) -> int:
51
+ args = _parser().parse_args(argv)
52
+ command = args.command or "test"
53
+ if command == "info":
54
+ print(json.dumps(broker_info(), indent=2))
55
+ return 0
56
+ if command == "prefix":
57
+ print(topic_prefix())
58
+ return 0
59
+
60
+ options = {
61
+ "transport": getattr(args, "transport", "tls"),
62
+ "host": getattr(args, "host", "broker.connectmq.com"),
63
+ "port": getattr(args, "port", None),
64
+ "username": getattr(args, "username", None),
65
+ "password": getattr(args, "password", None),
66
+ }
67
+ try:
68
+ if command == "test":
69
+ r = test(**options)
70
+ if r.ok:
71
+ print(f"OK {r.url} connect {r.connect_ms:.0f} ms, round-trip {r.round_trip_ms:.0f} ms")
72
+ return 0
73
+ print(f"FAIL {r.url} {r.error}")
74
+ return 1
75
+ with Client(**options) as client:
76
+ if command == "pub":
77
+ message = args.message if args.message is not None else sys.stdin.read().rstrip("\r\n")
78
+ client.publish(args.topic, message, qos=args.qos, retain=args.retain)
79
+ print(f"Published to {args.topic}", file=sys.stderr)
80
+ else:
81
+ print(f"Subscribed to {', '.join(args.topics)}. Press Ctrl+C to stop.", file=sys.stderr)
82
+ for m in client.messages(args.topics, qos=args.qos, count=args.count):
83
+ if args.json:
84
+ record = {"topic": m.topic, "payload": m.payload, "qos": m.qos, "retain": m.retain, "received": m.received.isoformat()}
85
+ print(json.dumps(record), flush=True)
86
+ else:
87
+ print(f"{m.received.astimezone():%H:%M:%S} {m.topic} {m.payload}", flush=True)
88
+ return 0
89
+ except KeyboardInterrupt:
90
+ return 0
91
+ except Exception as err: # noqa: BLE001 - CLI reports errors as text
92
+ print(f"Error: {err}", file=sys.stderr)
93
+ return 1
94
+
95
+
96
+ if __name__ == "__main__":
97
+ sys.exit(main())
@@ -0,0 +1,352 @@
1
+ """MQTT client preconfigured for the ConnectMQ public broker (broker.connectmq.com), built on paho-mqtt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import queue
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+ from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Tuple, Union
13
+
14
+ import paho.mqtt.client as mqtt
15
+
16
+ BROKER_HOST = "broker.connectmq.com"
17
+
18
+ ENDPOINTS: Dict[str, Tuple[str, int, Optional[str]]] = {
19
+ "tcp": ("mqtt", 1883, None),
20
+ "tls": ("mqtts", 8883, None),
21
+ "ws": ("ws", 8083, "/mqtt"),
22
+ "wss": ("wss", 8084, "/mqtt"),
23
+ }
24
+
25
+ Payload = Union[str, bytes, bytearray, int, float, dict, list, None]
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Message:
30
+ """A received MQTT message."""
31
+
32
+ topic: str
33
+ raw: bytes
34
+ qos: int
35
+ retain: bool
36
+ received: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
37
+
38
+ @property
39
+ def payload(self) -> str:
40
+ """Payload decoded as UTF-8."""
41
+ return self.raw.decode("utf-8", errors="replace")
42
+
43
+ def json(self) -> Any:
44
+ """Payload parsed as JSON."""
45
+ return json.loads(self.raw)
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class TestResult:
50
+ """Result of :func:`test`."""
51
+
52
+ url: str
53
+ ok: bool
54
+ connect_ms: Optional[float]
55
+ round_trip_ms: Optional[float]
56
+ error: Optional[str]
57
+
58
+
59
+ def broker_url(transport: str = "tls", host: str = BROKER_HOST, port: Optional[int] = None) -> str:
60
+ """URL of the broker for a transport: ``tcp``, ``tls``, ``ws`` or ``wss``."""
61
+ scheme, default_port, path = _endpoint(transport)
62
+ return f"{scheme}://{host}:{port or default_port}{path or ''}"
63
+
64
+
65
+ def broker_info() -> Dict[str, Any]:
66
+ """Endpoints and limits of the public broker."""
67
+ return {
68
+ "host": BROKER_HOST,
69
+ "urls": {name: broker_url(name) for name in ENDPOINTS},
70
+ "authentication": "Anonymous (non-empty client ID required)",
71
+ "limits": {
72
+ "max_message_size": "64 KB",
73
+ "publish_rate": "10 messages/second per client",
74
+ "subscriptions": 10,
75
+ "max_topic_depth": 8,
76
+ "qos": [0, 1, 2],
77
+ "session_expiry": "30 minutes",
78
+ },
79
+ "note": "Public area: anyone can read your messages. Get a private broker at https://connectmq.com",
80
+ }
81
+
82
+
83
+ def topic_prefix(root: str = "connectmq/demo") -> str:
84
+ """Random topic prefix such as ``connectmq/demo/7f3a91``, to avoid collisions on the shared public area."""
85
+ return f"{root}/{uuid.uuid4().hex[:6]}"
86
+
87
+
88
+ def topic_matches(topic_filter: str, topic: str) -> bool:
89
+ """MQTT topic filter matching with ``+``, ``#`` and ``$share/group/`` prefixes."""
90
+ if topic_filter.startswith("$share/"):
91
+ topic_filter = topic_filter.split("/", 2)[2] if topic_filter.count("/") >= 2 else ""
92
+ return mqtt.topic_matches_sub(topic_filter, topic)
93
+
94
+
95
+ def _endpoint(transport: str) -> Tuple[str, int, Optional[str]]:
96
+ try:
97
+ return ENDPOINTS[transport]
98
+ except KeyError:
99
+ raise ValueError(f"Unknown transport {transport!r}. Use tcp, tls, ws or wss.") from None
100
+
101
+
102
+ def _encode(payload: Payload) -> Union[str, bytes, bytearray, None]:
103
+ if payload is None or isinstance(payload, (str, bytes, bytearray)):
104
+ return payload
105
+ if isinstance(payload, (dict, list)):
106
+ return json.dumps(payload)
107
+ return str(payload)
108
+
109
+
110
+ class Client:
111
+ """Connected MQTT client. Use it as a context manager, or call :meth:`close` when done.
112
+
113
+ >>> with Client() as c:
114
+ ... c.publish("connectmq/demo/7f3a91/hello", "Hi")
115
+ """
116
+
117
+ def __init__(
118
+ self,
119
+ transport: str = "tls",
120
+ host: str = BROKER_HOST,
121
+ port: Optional[int] = None,
122
+ client_id: Optional[str] = None,
123
+ username: Optional[str] = None,
124
+ password: Optional[str] = None,
125
+ protocol: int = 5,
126
+ keepalive: int = 60,
127
+ timeout: float = 10.0,
128
+ ) -> None:
129
+ scheme, default_port, path = _endpoint(transport)
130
+ self.url = broker_url(transport, host, port)
131
+ self.client_id = client_id or f"py-{uuid.uuid4().hex[:8]}"
132
+ self.timeout = timeout
133
+ self._handlers: Dict[int, Tuple[List[str], Callable[[Message], None]]] = {}
134
+ self._next_handler = 0
135
+ self._lock = threading.Lock()
136
+ self._acks: Dict[int, "queue.Queue[Any]"] = {}
137
+ self._connected = threading.Event()
138
+ self._connect_result: Optional[str] = None
139
+
140
+ version = mqtt.MQTTv5 if protocol == 5 else mqtt.MQTTv311
141
+ self.mqtt = mqtt.Client(
142
+ mqtt.CallbackAPIVersion.VERSION2,
143
+ client_id=self.client_id,
144
+ protocol=version,
145
+ transport="websockets" if path else "tcp",
146
+ **({} if version == mqtt.MQTTv5 else {"clean_session": True}),
147
+ )
148
+ if path:
149
+ self.mqtt.ws_set_options(path=path)
150
+ if scheme in ("mqtts", "wss"):
151
+ self.mqtt.tls_set()
152
+ if username is not None:
153
+ self.mqtt.username_pw_set(username, password)
154
+ self.mqtt.on_connect = self._on_connect
155
+ self.mqtt.on_message = self._on_message
156
+ self.mqtt.on_subscribe = self._on_ack
157
+ self.mqtt.on_unsubscribe = self._on_ack
158
+
159
+ self.mqtt.connect_timeout = timeout
160
+ try:
161
+ self.mqtt.connect(host, port or default_port, keepalive)
162
+ except OSError as err:
163
+ raise ConnectionError(f"Could not connect to {self.url}: {err}") from err
164
+ self.mqtt.loop_start()
165
+ if not self._connected.wait(timeout):
166
+ self._stop()
167
+ raise TimeoutError(f"Timed out connecting to {self.url}.")
168
+ if self._connect_result:
169
+ self._stop()
170
+ raise ConnectionError(f"Broker refused the connection: {self._connect_result}")
171
+
172
+ # paho callbacks -------------------------------------------------------
173
+
174
+ def _on_connect(self, client, userdata, flags, reason_code, properties) -> None:
175
+ if reason_code.is_failure:
176
+ self._connect_result = str(reason_code)
177
+ self._connected.set()
178
+
179
+ def _on_ack(self, client, userdata, mid, reason_codes, properties) -> None:
180
+ with self._lock:
181
+ waiter = self._acks.get(mid)
182
+ if waiter is not None:
183
+ waiter.put(reason_codes)
184
+
185
+ def _on_message(self, client, userdata, msg) -> None:
186
+ message = Message(msg.topic, bytes(msg.payload), msg.qos, bool(msg.retain))
187
+ with self._lock:
188
+ handlers = list(self._handlers.values())
189
+ for filters, handler in handlers:
190
+ if any(topic_matches(f, msg.topic) for f in filters):
191
+ handler(message)
192
+
193
+ # public API -----------------------------------------------------------
194
+
195
+ @property
196
+ def is_connected(self) -> bool:
197
+ return self.mqtt.is_connected()
198
+
199
+ def publish(self, topic: str, payload: Payload = None, qos: int = 0, retain: bool = False) -> None:
200
+ """Publish a message. ``dict`` and ``list`` payloads are sent as JSON.
201
+ Publish ``""`` with ``retain=True`` to clear a retained message."""
202
+ info = self.mqtt.publish(topic, _encode(payload), qos=qos, retain=retain)
203
+ if info.rc != mqtt.MQTT_ERR_SUCCESS:
204
+ raise ConnectionError(f"Publish to {topic!r} failed: {mqtt.error_string(info.rc)}")
205
+ if qos > 0:
206
+ info.wait_for_publish(self.timeout)
207
+ if not info.is_published():
208
+ raise TimeoutError(f"Publish to {topic!r} was not acknowledged within {self.timeout} s.")
209
+
210
+ def subscribe(
211
+ self, topics: Union[str, Iterable[str]], callback: Callable[[Message], None], qos: int = 0
212
+ ) -> Callable[[], None]:
213
+ """Call ``callback(message)`` for every message on the topic filter(s).
214
+ Returns a function that unsubscribes. The callback is registered before the broker
215
+ acknowledges the subscription, so retained messages are not missed."""
216
+ filters = [topics] if isinstance(topics, str) else list(topics)
217
+ if not filters:
218
+ raise ValueError("At least one topic filter is required.")
219
+ with self._lock:
220
+ handler_id = self._next_handler
221
+ self._next_handler += 1
222
+ self._handlers[handler_id] = (filters, callback)
223
+ try:
224
+ codes = self._request(lambda: self.mqtt.subscribe([(f, qos) for f in filters]))
225
+ rejected = [f for f, c in zip(filters, codes) if getattr(c, "is_failure", False) or c == 128]
226
+ if rejected:
227
+ raise ValueError(f"Subscription rejected: {', '.join(rejected)}")
228
+ except Exception:
229
+ with self._lock:
230
+ self._handlers.pop(handler_id, None)
231
+ raise
232
+
233
+ def unsubscribe() -> None:
234
+ with self._lock:
235
+ if self._handlers.pop(handler_id, None) is None:
236
+ return
237
+ still_used = {f for fs, _ in self._handlers.values() for f in fs}
238
+ unused = [f for f in filters if f not in still_used]
239
+ if unused and self.is_connected:
240
+ self._request(lambda: self.mqtt.unsubscribe(unused))
241
+
242
+ return unsubscribe
243
+
244
+ def messages(
245
+ self, topics: Union[str, Iterable[str]], qos: int = 0, count: int = 0, timeout: Optional[float] = None
246
+ ) -> Iterator[Message]:
247
+ """Iterate over messages on the topic filter(s). Stops after ``count`` messages or
248
+ ``timeout`` seconds without a message (both optional)."""
249
+ inbox: "queue.Queue[Message]" = queue.Queue()
250
+ unsubscribe = self.subscribe(topics, inbox.put, qos)
251
+ try:
252
+ received = 0
253
+ while not count or received < count:
254
+ try:
255
+ yield inbox.get(timeout=timeout)
256
+ except queue.Empty:
257
+ return
258
+ received += 1
259
+ finally:
260
+ unsubscribe()
261
+
262
+ def once(self, topic: str, timeout: float = 30.0, qos: int = 0) -> Message:
263
+ """Wait for the next message on a topic filter. Raises ``TimeoutError``."""
264
+ for message in self.messages(topic, qos=qos, count=1, timeout=timeout):
265
+ return message
266
+ raise TimeoutError(f"No message on {topic!r} within {timeout} s.")
267
+
268
+ def close(self) -> None:
269
+ """Disconnect and stop the network thread."""
270
+ self._stop()
271
+
272
+ def _request(self, send: Callable[[], Tuple[int, Optional[int]]]) -> Any:
273
+ waiter: "queue.Queue[Any]" = queue.Queue()
274
+ with self._lock:
275
+ rc, mid = send()
276
+ if rc != mqtt.MQTT_ERR_SUCCESS or mid is None:
277
+ raise ConnectionError(f"Request failed: {mqtt.error_string(rc)}")
278
+ self._acks[mid] = waiter
279
+ try:
280
+ return waiter.get(timeout=self.timeout)
281
+ except queue.Empty:
282
+ raise TimeoutError("The broker did not acknowledge the request.") from None
283
+ finally:
284
+ with self._lock:
285
+ self._acks.pop(mid, None)
286
+
287
+ def _stop(self) -> None:
288
+ try:
289
+ if self.mqtt.is_connected():
290
+ self.mqtt.disconnect()
291
+ finally:
292
+ self.mqtt.loop_stop()
293
+
294
+ def __enter__(self) -> "Client":
295
+ return self
296
+
297
+ def __exit__(self, *exc: object) -> None:
298
+ self.close()
299
+
300
+ def __repr__(self) -> str:
301
+ return f"<connectmq.Client {self.url} client_id={self.client_id!r} connected={self.is_connected}>"
302
+
303
+
304
+ def connect(**options: Any) -> Client:
305
+ """Connect to broker.connectmq.com (or another broker) and return a :class:`Client`.
306
+ Options: transport, host, port, client_id, username, password, protocol, keepalive, timeout."""
307
+ return Client(**options)
308
+
309
+
310
+ def publish(topic: str, payload: Payload = None, qos: int = 0, retain: bool = False, **options: Any) -> None:
311
+ """Publish one message using a short-lived connection."""
312
+ with Client(**options) as client:
313
+ client.publish(topic, payload, qos=qos, retain=retain)
314
+
315
+
316
+ def subscribe(
317
+ topics: Union[str, Iterable[str]], qos: int = 0, count: int = 0, timeout: Optional[float] = None, **options: Any
318
+ ) -> Iterator[Message]:
319
+ """Iterate over messages using its own connection, which closes when iteration ends.
320
+
321
+ >>> for msg in subscribe("connectmq/demo/7f3a91/#", count=3):
322
+ ... print(msg.topic, msg.payload)
323
+ """
324
+ with Client(**options) as client:
325
+ yield from client.messages(topics, qos=qos, count=count, timeout=timeout)
326
+
327
+
328
+ def once(topic: str, timeout: float = 30.0, qos: int = 0, **options: Any) -> Message:
329
+ """Wait for the next message on a topic filter using a short-lived connection."""
330
+ with Client(**options) as client:
331
+ return client.once(topic, timeout=timeout, qos=qos)
332
+
333
+
334
+ def test(transport: str = "tls", **options: Any) -> TestResult:
335
+ """Connect, subscribe, publish and receive a test message. Never raises; check ``ok``."""
336
+ url = broker_url(transport, options.get("host", BROKER_HOST), options.get("port"))
337
+ started = time.perf_counter()
338
+ connect_ms = None
339
+ try:
340
+ with Client(transport=transport, **options) as client:
341
+ connect_ms = (time.perf_counter() - started) * 1000
342
+ topic = f"{topic_prefix()}/ping"
343
+ token = uuid.uuid4().hex
344
+ received = threading.Event()
345
+ client.subscribe(topic, lambda m: m.payload == token and received.set())
346
+ sent = time.perf_counter()
347
+ client.publish(topic, token)
348
+ if not received.wait(10):
349
+ raise TimeoutError("Round-trip timed out.")
350
+ return TestResult(url, True, connect_ms, (time.perf_counter() - sent) * 1000, None)
351
+ except Exception as err: # noqa: BLE001 - reported in the result
352
+ return TestResult(url, False, connect_ms, None, str(err))
File without changes