hivemind-mqtt-protocol 0.1.0a2__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,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: hivemind-mqtt-protocol
3
+ Version: 0.1.0a2
4
+ Summary: MQTT broker-mediated network protocol plugin for hivemind-core
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-mqtt-protocol
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: paho-mqtt>=1.6
10
+ Requires-Dist: hivemind-plugin-manager
11
+ Requires-Dist: poorman_handshake>=0.1.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: hivemind-core; extra == "test"
15
+ Requires-Dist: hivemind-bus-client; extra == "test"
16
+ Requires-Dist: ovos-bus-client; extra == "test"
17
+ Requires-Dist: ovos-utils; extra == "test"
18
+ Requires-Dist: hivescope; extra == "test"
@@ -0,0 +1,126 @@
1
+ # hivemind-mqtt-protocol
2
+
3
+ An MQTT broker-mediated network protocol plugin for [hivemind-core](https://github.com/JarbasHiveMind/hivemind-core).
4
+
5
+ Satellites connect to the same MQTT broker they already use for sensors (Home
6
+ Assistant, ESPHome, Tasmota, ESP32) and exchange encrypted `HiveMessage` frames
7
+ over that broker. No bespoke inbound port or WebSocket stack is required on the
8
+ hub; both hub and satellites are broker clients.
9
+
10
+ ## The broker-mediated model
11
+
12
+ ```
13
+ satellite ──pub──▶ broker ◀──sub── hub
14
+ hub ──pub──▶ broker ◀──sub── satellite
15
+ ```
16
+
17
+ The hub runs ONE paho-mqtt client. Logical per-satellite connections are
18
+ derived from the topic hierarchy.
19
+
20
+ ### Topic scheme
21
+
22
+ ```
23
+ <prefix>/<hub_id>/c2s/<satellite_id> # satellite → hub (hub subscribes …/c2s/+)
24
+ <prefix>/<hub_id>/s2c/<satellite_id> # hub → satellite
25
+ <prefix>/<hub_id>/status/<satellite_id> # retained LWT presence (online / offline)
26
+ ```
27
+
28
+ Defaults: `prefix = hivemind`, `hub_id` = node identity name.
29
+
30
+ ### Privacy option
31
+
32
+ Set `hash_topics: true` to SHA-256-hash the `satellite_id` segment. The broker
33
+ then sees only an opaque 16-char hex token — useful when the broker is shared or
34
+ untrusted.
35
+
36
+ ## Crypto
37
+
38
+ The MQTT payload carries the **same encrypted HiveMessage frame** the WebSocket
39
+ transport sends. The broker only ever sees ciphertext. HiveMind's full
40
+ AES-GCM / RSA / PAKE handshake runs unchanged inside the payload.
41
+
42
+ ## Authentication
43
+
44
+ Two independent layers:
45
+
46
+ 1. **Broker-level** — MQTT `username` / `password`, or TLS client-cert.
47
+ Configure the broker's ACL so each satellite may only publish to its own
48
+ `c2s/<key>` topic and subscribe to its own `s2c/<key>` topic.
49
+
50
+ 2. **HiveMind-level** — the HELLO / HANDSHAKE exchange embedded in the
51
+ encrypted payload, identical to the WebSocket path. The satellite's MQTT
52
+ username **must equal** its HiveMind access key so the hub can look up the
53
+ DB record on first contact.
54
+
55
+ ## QoS
56
+
57
+ | Traffic type | QoS |
58
+ |---|---|
59
+ | Control frames (default) | 1 (at-least-once) |
60
+ | Binary / audio frames | 0 (fire-and-forget, low latency) |
61
+
62
+ ## Configuration keys
63
+
64
+ | Key | Default | Description |
65
+ |---|---|---|
66
+ | `broker_host` | `localhost` | MQTT broker hostname or IP |
67
+ | `broker_port` | `1883` | Broker port (8883 for TLS) |
68
+ | `broker_username` | — | MQTT username for the hub |
69
+ | `broker_password` | — | MQTT password for the hub |
70
+ | `tls` | `false` | Enable TLS |
71
+ | `tls_ca_certs` | — | Path to CA bundle |
72
+ | `tls_certfile` | — | Path to client cert (mTLS) |
73
+ | `tls_keyfile` | — | Path to client key (mTLS) |
74
+ | `hub_id` | node identity name | Hub identifier in topics |
75
+ | `topic_prefix` | `hivemind` | Topic namespace prefix |
76
+ | `qos` | `1` | Default MQTT QoS for control frames |
77
+ | `hash_topics` | `false` | Hash `satellite_id` in topics |
78
+ | `idle_timeout` | `300` | Seconds of silence before evicting a peer (0 = off) |
79
+
80
+ ## Usage
81
+
82
+ ```python
83
+ from hivemind_plugin_manager import NetworkProtocolFactory
84
+
85
+ server = NetworkProtocolFactory.create(
86
+ "hivemind-mqtt-plugin",
87
+ config={
88
+ "broker_host": "192.168.1.100",
89
+ "broker_port": 1883,
90
+ "hub_id": "living-room-hub",
91
+ },
92
+ )
93
+ server.run() # blocks
94
+ ```
95
+
96
+ ## Satellite side
97
+
98
+ The matching satellite client (publish to `c2s`, subscribe to `s2c`, set the
99
+ LWT on `status/<satellite_id>`) is a planned follow-up as a transport option in
100
+ `hivemind-bus-client` or a dedicated `hivemind-mqtt-client`. An
101
+ ESPHome / Tasmota external-component example for ESP32 satellites is also
102
+ planned.
103
+
104
+ ## Where it fits
105
+
106
+ ```
107
+ hivemind-core
108
+ └── hivemind-plugin-manager (NetworkProtocolFactory loads plugins by entry-point)
109
+ └── hivemind-mqtt-protocol ← this repo
110
+ └── paho-mqtt client connected to an external MQTT broker
111
+ ```
112
+
113
+ The plugin registers under the `hivemind.network.protocol` entry-point group as
114
+ `hivemind-mqtt-plugin`.
115
+
116
+ ## Docs
117
+
118
+ - [docs/architecture.md](docs/architecture.md) — topic scheme, crypto, QoS, idle eviction
119
+ - [docs/configuration.md](docs/configuration.md) — full configuration reference
120
+ - [docs/operations.md](docs/operations.md) — broker setup, TLS/mTLS, authoring a transport plugin
121
+
122
+ ## Install
123
+
124
+ ```bash
125
+ pip install hivemind-mqtt-protocol
126
+ ```
@@ -0,0 +1,389 @@
1
+ """
2
+ HiveMind MQTT Network Protocol Plugin
3
+
4
+ Transports encrypted HiveMessage frames over an MQTT broker so that any
5
+ satellite — including embedded ESP32 devices — can ride an existing IoT/HA
6
+ MQTT bus rather than opening a dedicated WebSocket connection.
7
+
8
+ Topology
9
+ --------
10
+ The hub runs ONE paho-mqtt client that connects to an external broker. There
11
+ is no accept() loop; logical per-satellite "connections" are derived from the
12
+ topic hierarchy:
13
+
14
+ <prefix>/<hub_id>/c2s/<satellite_id> satellite → hub (hub subscribes …/c2s/+)
15
+ <prefix>/<hub_id>/s2c/<satellite_id> hub → satellite
16
+ <prefix>/<hub_id>/status/<satellite_id> retained LWT presence (online/offline)
17
+
18
+ Crypto
19
+ ------
20
+ The MQTT payload IS the same encrypted HiveMessage frame that the WebSocket
21
+ transport sends. The broker only ever sees ciphertext. No extra encryption
22
+ layer is added here; hivemind-core's AES-GCM / RSA / PAKE handshake runs
23
+ unchanged inside the payload bytes.
24
+
25
+ Auth
26
+ ----
27
+ Two layers, consistent with the design doc:
28
+ 1. Broker-level: MQTT username/password or TLS client-cert (config keys
29
+ ``broker_username`` / ``broker_password`` / ``tls`` / ``cert``).
30
+ 2. HiveMind-level: the HELLO/HANDSHAKE in-payload exchange, identical to the
31
+ WebSocket path. The access key is passed as the MQTT username (the
32
+ satellite MUST set username=<access_key>).
33
+ """
34
+
35
+ import dataclasses
36
+ import hashlib
37
+ import threading
38
+ import time
39
+ from dataclasses import dataclass, field
40
+ from typing import Any, Dict, Optional
41
+
42
+ import paho.mqtt.client as mqtt
43
+ from ovos_bus_client.session import Session
44
+ from ovos_utils.log import LOG
45
+ from poorman_handshake import PasswordHandShake
46
+
47
+ from hivemind_core.protocol import (
48
+ HiveMindClientConnection,
49
+ HiveMindListenerProtocol,
50
+ HiveMindNodeType,
51
+ )
52
+ from hivemind_plugin_manager.protocols import ClientCallbacks, NetworkProtocol
53
+
54
+ _ONLINE = "online"
55
+ _OFFLINE = "offline"
56
+
57
+ # Seconds of silence from a peer before it is considered idle-disconnected.
58
+ # Set to 0 to disable the sweep.
59
+ _DEFAULT_IDLE_TIMEOUT = 300
60
+
61
+
62
+ @dataclass
63
+ class HiveMindMqttProtocol(NetworkProtocol):
64
+ """MQTT broker-mediated network protocol for hivemind-core.
65
+
66
+ Config keys (all optional, with defaults shown):
67
+ broker_host (str) "localhost"
68
+ broker_port (int) 1883
69
+ broker_username (str) None — MQTT username (not the HiveMind key)
70
+ broker_password (str) None
71
+ tls (bool) False — enable TLS
72
+ tls_ca_certs (str) None — path to CA bundle
73
+ tls_certfile (str) None — path to client cert (mTLS)
74
+ tls_keyfile (str) None — path to client key (mTLS)
75
+ hub_id (str) NodeIdentity.name or "hivemind-hub"
76
+ topic_prefix (str) "hivemind"
77
+ qos (int) 1
78
+ hash_topics (bool) False — hash satellite_id in topics for privacy
79
+ idle_timeout (int) 300 — seconds; 0 disables
80
+ """
81
+
82
+ config: Dict[str, Any] = field(default_factory=dict)
83
+ hm_protocol: Optional[HiveMindListenerProtocol] = None
84
+ callbacks: ClientCallbacks = field(default_factory=ClientCallbacks)
85
+
86
+ # Internal state — not part of the dataclass constructor signature.
87
+ _peers: Dict[str, HiveMindClientConnection] = field(default_factory=dict, init=False, repr=False)
88
+ _last_seen: Dict[str, float] = field(default_factory=dict, init=False, repr=False)
89
+ _mqtt: Optional[mqtt.Client] = field(default=None, init=False, repr=False)
90
+ _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
91
+
92
+ # ------------------------------------------------------------------
93
+ # Helpers
94
+ # ------------------------------------------------------------------
95
+
96
+ def _cfg(self, key: str, default: Any = None) -> Any:
97
+ return self.config.get(key, default)
98
+
99
+ def _hub_id(self) -> str:
100
+ return str(self._cfg("hub_id") or self.identity.name or "hivemind-hub")
101
+
102
+ def _prefix(self) -> str:
103
+ return str(self._cfg("topic_prefix") or "hivemind")
104
+
105
+ def _qos(self, is_bin: bool = False) -> int:
106
+ """QoS 0 for binary/audio (latency-sensitive); QoS 1 for control."""
107
+ if is_bin:
108
+ return 0
109
+ v = self._cfg("qos")
110
+ return int(v) if v is not None else 1
111
+
112
+ def _sat_topic_seg(self, satellite_id: str) -> str:
113
+ """Return the topic segment for satellite_id, optionally hashed."""
114
+ if self._cfg("hash_topics", False):
115
+ return hashlib.sha256(satellite_id.encode()).hexdigest()[:16]
116
+ return satellite_id
117
+
118
+ # topic builders ---------------------------------------------------
119
+
120
+ def c2s_topic(self, satellite_id: str) -> str:
121
+ """Inbound topic: satellite → hub."""
122
+ return f"{self._prefix()}/{self._hub_id()}/c2s/{self._sat_topic_seg(satellite_id)}"
123
+
124
+ def s2c_topic(self, satellite_id: str) -> str:
125
+ """Outbound topic: hub → satellite."""
126
+ return f"{self._prefix()}/{self._hub_id()}/s2c/{self._sat_topic_seg(satellite_id)}"
127
+
128
+ def status_topic(self, satellite_id: str) -> str:
129
+ """Retained LWT presence topic."""
130
+ return f"{self._prefix()}/{self._hub_id()}/status/{self._sat_topic_seg(satellite_id)}"
131
+
132
+ def c2s_wildcard(self) -> str:
133
+ """Wildcard subscription for all satellites' c2s traffic."""
134
+ return f"{self._prefix()}/{self._hub_id()}/c2s/+"
135
+
136
+ def status_wildcard(self) -> str:
137
+ """Wildcard subscription for all satellites' status topics."""
138
+ return f"{self._prefix()}/{self._hub_id()}/status/+"
139
+
140
+ # satellite_id extraction ------------------------------------------
141
+
142
+ @staticmethod
143
+ def _satellite_id_from_topic(topic: str) -> Optional[str]:
144
+ """Parse the satellite_id segment from a c2s or status topic."""
145
+ parts = topic.split("/")
146
+ # expected: <prefix>/<hub_id>/<segment>/<satellite_id>
147
+ if len(parts) >= 4:
148
+ return parts[-1]
149
+ return None
150
+
151
+ # ------------------------------------------------------------------
152
+ # Connection lifecycle
153
+ # ------------------------------------------------------------------
154
+
155
+ def _build_client_connection(
156
+ self, satellite_id: str, key: str, useragent: str
157
+ ) -> Optional[HiveMindClientConnection]:
158
+ """Create and register a HiveMindClientConnection for a new peer.
159
+
160
+ Mirrors HiveMindTornadoWebSocket.open() exactly: look up DB client by
161
+ key, populate ACL fields, then call handle_new_client.
162
+
163
+ Returns the new connection or None if auth fails.
164
+ """
165
+ prefix = self._prefix()
166
+ hub_id = self._hub_id()
167
+ mqttclient = self._mqtt
168
+ qos_fn = self._qos
169
+ s2c = self.s2c_topic(satellite_id)
170
+ status = self.status_topic(satellite_id)
171
+
172
+ def do_send(payload: Any, is_bin: bool = False) -> None:
173
+ if isinstance(payload, str):
174
+ payload = payload.encode()
175
+ mqttclient.publish(s2c, payload, qos=qos_fn(is_bin))
176
+
177
+ def do_disconnect() -> None:
178
+ # Publish an offline tombstone then clean up.
179
+ mqttclient.publish(status, _OFFLINE, qos=1, retain=True)
180
+ with self._lock:
181
+ self._peers.pop(satellite_id, None)
182
+ self._last_seen.pop(satellite_id, None)
183
+
184
+ conn = HiveMindClientConnection(
185
+ key=key,
186
+ disconnect=do_disconnect,
187
+ send_msg=do_send,
188
+ sess=Session(session_id="default"),
189
+ name=useragent,
190
+ hm_protocol=self.hm_protocol,
191
+ )
192
+
193
+ self.hm_protocol.db.sync()
194
+ user = self.hm_protocol.db.get_client_by_api_key(key)
195
+
196
+ if not user:
197
+ LOG.error(f"[MQTT] Client {satellite_id!r} provided invalid API key")
198
+ self.hm_protocol.handle_invalid_key_connected(conn)
199
+ return None
200
+
201
+ conn.name = f"{useragent}::{user.client_id}::{user.name}"
202
+ conn.crypto_key = user.crypto_key
203
+ conn.msg_blacklist = user.message_blacklist or []
204
+ conn.skill_blacklist = user.skill_blacklist or []
205
+ conn.intent_blacklist = user.intent_blacklist or []
206
+ conn.allowed_types = user.allowed_types
207
+ conn.can_broadcast = user.can_broadcast
208
+ conn.can_propagate = user.can_propagate
209
+ conn.can_escalate = user.can_escalate
210
+ conn.is_admin = user.is_admin
211
+ if user.password:
212
+ conn.pswd_handshake = PasswordHandShake(user.password)
213
+
214
+ conn.node_type = HiveMindNodeType.NODE
215
+
216
+ if (
217
+ not conn.crypto_key
218
+ and not self.hm_protocol.handshake_enabled
219
+ and self.hm_protocol.require_crypto
220
+ ):
221
+ LOG.error(
222
+ "[MQTT] No pre-shared crypto key and handshake disabled, "
223
+ "but require_crypto=True"
224
+ )
225
+ self.hm_protocol.handle_invalid_protocol_version(conn)
226
+ return None
227
+
228
+ with self._lock:
229
+ self._peers[satellite_id] = conn
230
+ self._last_seen[satellite_id] = time.monotonic()
231
+
232
+ self.hm_protocol.handle_new_client(conn)
233
+ LOG.info(f"[MQTT] New logical connection: {satellite_id!r} → {conn.name!r}")
234
+ return conn
235
+
236
+ def _disconnect_peer(self, satellite_id: str) -> None:
237
+ """Tear down the logical connection for satellite_id (LWT or timeout)."""
238
+ with self._lock:
239
+ conn = self._peers.pop(satellite_id, None)
240
+ self._last_seen.pop(satellite_id, None)
241
+ if conn is not None:
242
+ LOG.info(f"[MQTT] Disconnecting peer {satellite_id!r}")
243
+ self.hm_protocol.handle_client_disconnected(conn)
244
+
245
+ # ------------------------------------------------------------------
246
+ # paho callbacks
247
+ # ------------------------------------------------------------------
248
+
249
+ def _on_connect(self, client: mqtt.Client, userdata: Any, flags: Any, rc: int) -> None:
250
+ if rc != 0:
251
+ LOG.error(f"[MQTT] Broker connection failed, rc={rc}")
252
+ return
253
+ LOG.info("[MQTT] Connected to broker")
254
+ client.subscribe(self.c2s_wildcard(), qos=self._qos())
255
+ client.subscribe(self.status_wildcard(), qos=1)
256
+ LOG.debug(f"[MQTT] Subscribed to {self.c2s_wildcard()} and {self.status_wildcard()}")
257
+
258
+ def _on_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None:
259
+ topic: str = msg.topic
260
+ payload: bytes = msg.payload
261
+
262
+ # Determine segment type (c2s or status).
263
+ parts = topic.split("/")
264
+ if len(parts) < 4:
265
+ LOG.warning(f"[MQTT] Unexpected topic shape: {topic!r}")
266
+ return
267
+
268
+ segment = parts[-2] # "c2s" or "status"
269
+ satellite_id = parts[-1]
270
+
271
+ if segment == "status":
272
+ status_val = payload.decode(errors="replace").strip()
273
+ if status_val == _OFFLINE:
274
+ LOG.info(f"[MQTT] LWT offline for {satellite_id!r}")
275
+ self._disconnect_peer(satellite_id)
276
+ return
277
+
278
+ if segment != "c2s":
279
+ return
280
+
281
+ # Inbound data frame.
282
+ with self._lock:
283
+ conn = self._peers.get(satellite_id)
284
+ if conn is not None:
285
+ self._last_seen[satellite_id] = time.monotonic()
286
+
287
+ if conn is None:
288
+ # Unknown satellite — must authenticate via the MQTT username.
289
+ # The hub's MQTT client receives the *message*; MQTT does not expose
290
+ # the sender's credentials directly here. We use the satellite_id
291
+ # itself as a key placeholder so the payload HELLO frame can carry
292
+ # the real access key through the normal HiveMind handshake.
293
+ # For brokers that enforce ACLs, the MQTT username IS the HiveMind
294
+ # access key; we record it from the first-frame satellite_id.
295
+ # Treat satellite_id as the key for the initial lookup so that the
296
+ # broker-ACL username == HiveMind access key pattern works.
297
+ useragent = satellite_id
298
+ key = satellite_id # satellite_id == MQTT username == HiveMind key
299
+ conn = self._build_client_connection(satellite_id, key, useragent)
300
+ if conn is None:
301
+ # Auth failed; drop frame.
302
+ return
303
+
304
+ try:
305
+ message = conn.decode(payload)
306
+ except Exception as e:
307
+ LOG.warning(f"[MQTT] Failed to decode frame from {satellite_id!r}: {e}")
308
+ return
309
+
310
+ self.hm_protocol.handle_message(message, conn)
311
+
312
+ def _on_disconnect(self, client: mqtt.Client, userdata: Any, rc: int) -> None:
313
+ if rc != 0:
314
+ LOG.warning(f"[MQTT] Unexpected broker disconnect, rc={rc}")
315
+
316
+ # ------------------------------------------------------------------
317
+ # Idle-timeout sweep
318
+ # ------------------------------------------------------------------
319
+
320
+ def _idle_sweep(self, idle_timeout: float) -> None:
321
+ """Background thread: evict peers that have been silent too long."""
322
+ while True:
323
+ time.sleep(max(idle_timeout / 4, 30))
324
+ now = time.monotonic()
325
+ with self._lock:
326
+ stale = [
327
+ sid
328
+ for sid, ts in list(self._last_seen.items())
329
+ if (now - ts) > idle_timeout
330
+ ]
331
+ for sid in stale:
332
+ LOG.info(f"[MQTT] Idle timeout for peer {sid!r}")
333
+ self._disconnect_peer(sid)
334
+
335
+ # ------------------------------------------------------------------
336
+ # run() — the blocking entry point called by hivemind-core
337
+ # ------------------------------------------------------------------
338
+
339
+ def run(self) -> None:
340
+ LOG.debug(f"[MQTT] protocol config: {self.config}")
341
+
342
+ broker_host: str = str(self._cfg("broker_host") or "localhost")
343
+ broker_port: int = int(self._cfg("broker_port") or 1883)
344
+ hub_id = self._hub_id()
345
+
346
+ client_id = f"hivemind-hub-{hub_id}"
347
+ self._mqtt = mqtt.Client(client_id=client_id)
348
+
349
+ # Broker-level auth.
350
+ username: Optional[str] = self._cfg("broker_username")
351
+ password: Optional[str] = self._cfg("broker_password")
352
+ if username:
353
+ self._mqtt.username_pw_set(username, password)
354
+
355
+ # TLS.
356
+ if self._cfg("tls", False):
357
+ self._mqtt.tls_set(
358
+ ca_certs=self._cfg("tls_ca_certs"),
359
+ certfile=self._cfg("tls_certfile"),
360
+ keyfile=self._cfg("tls_keyfile"),
361
+ )
362
+
363
+ # Hub's own LWT — signals the hub going offline to any listener.
364
+ hub_status_topic = f"{self._prefix()}/{hub_id}/status/hub"
365
+ self._mqtt.will_set(hub_status_topic, _OFFLINE, qos=1, retain=True)
366
+
367
+ self._mqtt.on_connect = self._on_connect
368
+ self._mqtt.on_message = self._on_message
369
+ self._mqtt.on_disconnect = self._on_disconnect
370
+
371
+ self._mqtt.connect(broker_host, broker_port, keepalive=60)
372
+
373
+ # Publish hub online status once connected (done in on_connect would
374
+ # need the client reference; simpler to publish after connect()).
375
+ self._mqtt.publish(hub_status_topic, _ONLINE, qos=1, retain=True)
376
+
377
+ # Start idle-timeout sweep if configured.
378
+ idle_timeout = float(self._cfg("idle_timeout") or _DEFAULT_IDLE_TIMEOUT)
379
+ if idle_timeout > 0:
380
+ t = threading.Thread(
381
+ target=self._idle_sweep,
382
+ args=(idle_timeout,),
383
+ daemon=True,
384
+ name="mqtt-idle-sweep",
385
+ )
386
+ t.start()
387
+
388
+ LOG.info(f"[MQTT] listener started — broker={broker_host}:{broker_port}, hub_id={hub_id!r}")
389
+ self._mqtt.loop_forever() # blocking — mirrors tornado ioloop.start()
@@ -0,0 +1,8 @@
1
+ # START_VERSION_BLOCK
2
+ VERSION_MAJOR = 0
3
+ VERSION_MINOR = 1
4
+ VERSION_BUILD = 0
5
+ VERSION_ALPHA = 2
6
+ # END_VERSION_BLOCK
7
+
8
+ __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: hivemind-mqtt-protocol
3
+ Version: 0.1.0a2
4
+ Summary: MQTT broker-mediated network protocol plugin for hivemind-core
5
+ Author-email: JarbasAi <jarbasai@mailfence.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/JarbasHiveMind/hivemind-mqtt-protocol
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: paho-mqtt>=1.6
10
+ Requires-Dist: hivemind-plugin-manager
11
+ Requires-Dist: poorman_handshake>=0.1.0
12
+ Provides-Extra: test
13
+ Requires-Dist: pytest; extra == "test"
14
+ Requires-Dist: hivemind-core; extra == "test"
15
+ Requires-Dist: hivemind-bus-client; extra == "test"
16
+ Requires-Dist: ovos-bus-client; extra == "test"
17
+ Requires-Dist: ovos-utils; extra == "test"
18
+ Requires-Dist: hivescope; extra == "test"
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ hivemind_mqtt_protocol/__init__.py
4
+ hivemind_mqtt_protocol/version.py
5
+ hivemind_mqtt_protocol.egg-info/PKG-INFO
6
+ hivemind_mqtt_protocol.egg-info/SOURCES.txt
7
+ hivemind_mqtt_protocol.egg-info/dependency_links.txt
8
+ hivemind_mqtt_protocol.egg-info/entry_points.txt
9
+ hivemind_mqtt_protocol.egg-info/requires.txt
10
+ hivemind_mqtt_protocol.egg-info/top_level.txt
11
+ tests/test_mqtt_protocol.py
@@ -0,0 +1,2 @@
1
+ [hivemind.network.protocol]
2
+ hivemind-mqtt-plugin = hivemind_mqtt_protocol:HiveMindMqttProtocol
@@ -0,0 +1,11 @@
1
+ paho-mqtt>=1.6
2
+ hivemind-plugin-manager
3
+ poorman_handshake>=0.1.0
4
+
5
+ [test]
6
+ pytest
7
+ hivemind-core
8
+ hivemind-bus-client
9
+ ovos-bus-client
10
+ ovos-utils
11
+ hivescope
@@ -0,0 +1 @@
1
+ hivemind_mqtt_protocol
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hivemind-mqtt-protocol"
7
+ dynamic = ["version"]
8
+ description = "MQTT broker-mediated network protocol plugin for hivemind-core"
9
+ license = "Apache-2.0"
10
+ authors = [
11
+ { name = "JarbasAi", email = "jarbasai@mailfence.com" }
12
+ ]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "paho-mqtt>=1.6",
16
+ "hivemind-plugin-manager",
17
+ "poorman_handshake>=0.1.0",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ test = [
22
+ "pytest",
23
+ "hivemind-core",
24
+ "hivemind-bus-client",
25
+ "ovos-bus-client",
26
+ "ovos-utils",
27
+ "hivescope",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/JarbasHiveMind/hivemind-mqtt-protocol"
32
+
33
+ [project.entry-points."hivemind.network.protocol"]
34
+ "hivemind-mqtt-plugin" = "hivemind_mqtt_protocol:HiveMindMqttProtocol"
35
+
36
+ [tool.setuptools.dynamic]
37
+ version = { attr = "hivemind_mqtt_protocol.version.__version__" }
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["."]
41
+ include = ["hivemind_mqtt_protocol*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,451 @@
1
+ """
2
+ Unit tests for hivemind_mqtt_protocol.HiveMindMqttProtocol.
3
+
4
+ All tests use mocked paho-mqtt and mocked hivemind-core objects — no live
5
+ broker is required.
6
+ """
7
+
8
+ import hashlib
9
+ import threading
10
+ import time
11
+ import types
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Dict, Optional
14
+ from unittest.mock import MagicMock, call, patch
15
+
16
+ import pytest
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Minimal stubs so we can import the protocol without a full HiveMind install.
20
+ # ---------------------------------------------------------------------------
21
+
22
+ import sys
23
+
24
+
25
+ def _make_stub_module(name: str, **attrs) -> types.ModuleType:
26
+ mod = types.ModuleType(name)
27
+ for k, v in attrs.items():
28
+ setattr(mod, k, v)
29
+ return mod
30
+
31
+
32
+ # ovos_utils.log
33
+ log_stub = _make_stub_module("ovos_utils")
34
+ log_stub.log = _make_stub_module("ovos_utils.log", LOG=MagicMock())
35
+ sys.modules.setdefault("ovos_utils", log_stub)
36
+ sys.modules.setdefault("ovos_utils.log", log_stub.log)
37
+
38
+ # ovos_bus_client.session
39
+ session_cls = MagicMock(name="Session")
40
+ session_stub = _make_stub_module("ovos_bus_client")
41
+ session_stub.session = _make_stub_module("ovos_bus_client.session", Session=session_cls)
42
+ sys.modules.setdefault("ovos_bus_client", session_stub)
43
+ sys.modules.setdefault("ovos_bus_client.session", session_stub.session)
44
+
45
+ # poorman_handshake
46
+ psh_stub = _make_stub_module("poorman_handshake", PasswordHandShake=MagicMock())
47
+ sys.modules.setdefault("poorman_handshake", psh_stub)
48
+
49
+ # hivemind_plugin_manager.protocols
50
+ @dataclass
51
+ class _FakeNetworkProtocol:
52
+ config: Dict[str, Any] = field(default_factory=dict)
53
+ hm_protocol: Optional[Any] = None
54
+ callbacks: Any = None
55
+
56
+ @property
57
+ def identity(self):
58
+ return MagicMock(name="identity")
59
+
60
+ @property
61
+ def database(self):
62
+ return None
63
+
64
+ @property
65
+ def clients(self):
66
+ return {}
67
+
68
+ @property
69
+ def agent_protocol(self):
70
+ return None
71
+
72
+ def run(self): # abstract placeholder
73
+ pass
74
+
75
+
76
+ class _FakeCallbacks:
77
+ pass
78
+
79
+
80
+ proto_mod = _make_stub_module(
81
+ "hivemind_plugin_manager.protocols",
82
+ NetworkProtocol=_FakeNetworkProtocol,
83
+ ClientCallbacks=_FakeCallbacks,
84
+ )
85
+ pm_mod = _make_stub_module("hivemind_plugin_manager", protocols=proto_mod)
86
+ sys.modules.setdefault("hivemind_plugin_manager", pm_mod)
87
+ sys.modules.setdefault("hivemind_plugin_manager.protocols", proto_mod)
88
+
89
+ # hivemind_core.protocol
90
+ class _FakeClientConnection:
91
+ def __init__(self, **kwargs):
92
+ for k, v in kwargs.items():
93
+ setattr(self, k, v)
94
+ self.peer = kwargs.get("name", "peer")
95
+ self.crypto_key = None
96
+ self.msg_blacklist = []
97
+ self.skill_blacklist = []
98
+ self.intent_blacklist = []
99
+ self.allowed_types = []
100
+ self.can_broadcast = True
101
+ self.can_propagate = True
102
+ self.can_escalate = True
103
+ self.is_admin = False
104
+ self.pswd_handshake = None
105
+ self.node_type = None
106
+ self._decode_result = MagicMock(name="HiveMessage")
107
+
108
+ def decode(self, payload):
109
+ return self._decode_result
110
+
111
+
112
+ class _FakeNodeType:
113
+ NODE = "NODE"
114
+
115
+
116
+ hmc_mod = _make_stub_module(
117
+ "hivemind_core.protocol",
118
+ HiveMindClientConnection=_FakeClientConnection,
119
+ HiveMindListenerProtocol=MagicMock(),
120
+ HiveMindNodeType=_FakeNodeType,
121
+ )
122
+ sys.modules.setdefault("hivemind_core", _make_stub_module("hivemind_core"))
123
+ sys.modules.setdefault("hivemind_core.protocol", hmc_mod)
124
+
125
+ # Now patch NetworkProtocol base in the module under test before importing.
126
+ import hivemind_mqtt_protocol # noqa: E402 (import after stubs)
127
+
128
+ # Patch the base class reference inside the module so HiveMindMqttProtocol
129
+ # inherits from our stub instead of the real NetworkProtocol.
130
+ hivemind_mqtt_protocol.NetworkProtocol = _FakeNetworkProtocol
131
+ hivemind_mqtt_protocol.HiveMindClientConnection = _FakeClientConnection
132
+ hivemind_mqtt_protocol.HiveMindNodeType = _FakeNodeType
133
+ hivemind_mqtt_protocol.ClientCallbacks = _FakeCallbacks
134
+
135
+ from hivemind_mqtt_protocol import HiveMindMqttProtocol # noqa: E402
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Fixtures
140
+ # ---------------------------------------------------------------------------
141
+
142
+
143
+ def _make_protocol(config=None):
144
+ """Return a protocol instance with a mocked hm_protocol and mqtt client."""
145
+ hm = MagicMock(name="hm_protocol")
146
+ hm.identity = MagicMock(name="identity")
147
+ hm.identity.name = "testhub"
148
+ hm.handshake_enabled = True
149
+ hm.require_crypto = False
150
+ # Always inject hub_id via config so _hub_id() doesn't go through the
151
+ # identity property chain (MagicMock.name is a reserved attribute).
152
+ base_config = {"hub_id": "testhub"}
153
+ if config:
154
+ base_config.update(config)
155
+ config = base_config
156
+
157
+ # DB returns a valid user for any key.
158
+ user = MagicMock(name="user")
159
+ user.client_id = 42
160
+ user.name = "testclient"
161
+ user.crypto_key = None
162
+ user.message_blacklist = []
163
+ user.skill_blacklist = []
164
+ user.intent_blacklist = []
165
+ user.allowed_types = []
166
+ user.can_broadcast = True
167
+ user.can_propagate = True
168
+ user.can_escalate = True
169
+ user.is_admin = False
170
+ user.password = None
171
+ hm.db.get_client_by_api_key.return_value = user
172
+
173
+ p = HiveMindMqttProtocol(config=config or {}, hm_protocol=hm)
174
+ p._peers = {}
175
+ p._last_seen = {}
176
+ p._lock = threading.Lock()
177
+ mock_mqtt = MagicMock(name="mqtt_client")
178
+ p._mqtt = mock_mqtt
179
+ return p
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Topic build / parse tests
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ class TestTopics:
188
+ def test_c2s_topic_default_prefix(self):
189
+ p = _make_protocol()
190
+ assert p.c2s_topic("sat1") == "hivemind/testhub/c2s/sat1"
191
+
192
+ def test_s2c_topic_default_prefix(self):
193
+ p = _make_protocol()
194
+ assert p.s2c_topic("sat1") == "hivemind/testhub/s2c/sat1"
195
+
196
+ def test_status_topic_default_prefix(self):
197
+ p = _make_protocol()
198
+ assert p.status_topic("sat1") == "hivemind/testhub/status/sat1"
199
+
200
+ def test_custom_prefix_and_hub_id(self):
201
+ p = _make_protocol({"topic_prefix": "hm", "hub_id": "myhub"})
202
+ assert p.c2s_topic("x") == "hm/myhub/c2s/x"
203
+
204
+ def test_c2s_wildcard(self):
205
+ p = _make_protocol()
206
+ assert p.c2s_wildcard() == "hivemind/testhub/c2s/+"
207
+
208
+ def test_status_wildcard(self):
209
+ p = _make_protocol()
210
+ assert p.status_wildcard() == "hivemind/testhub/status/+"
211
+
212
+ def test_hash_topics(self):
213
+ p = _make_protocol({"hash_topics": True})
214
+ sat_id = "mysat"
215
+ expected = hashlib.sha256(sat_id.encode()).hexdigest()[:16]
216
+ assert p.c2s_topic(sat_id) == f"hivemind/testhub/c2s/{expected}"
217
+
218
+ def test_satellite_id_from_c2s_topic(self):
219
+ topic = "hivemind/testhub/c2s/sat42"
220
+ assert HiveMindMqttProtocol._satellite_id_from_topic(topic) == "sat42"
221
+
222
+ def test_satellite_id_from_status_topic(self):
223
+ topic = "hivemind/myhub/status/sat99"
224
+ assert HiveMindMqttProtocol._satellite_id_from_topic(topic) == "sat99"
225
+
226
+ def test_satellite_id_short_topic_returns_none(self):
227
+ assert HiveMindMqttProtocol._satellite_id_from_topic("bad") is None
228
+
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Peer-map: new peer / known peer logic
232
+ # ---------------------------------------------------------------------------
233
+
234
+
235
+ class TestPeerMap:
236
+ def test_new_peer_added_to_map(self):
237
+ p = _make_protocol()
238
+ conn = p._build_client_connection("sat1", "sat1", "sat1")
239
+ assert "sat1" in p._peers
240
+ assert conn is not None
241
+
242
+ def test_known_peer_not_duplicated(self):
243
+ p = _make_protocol()
244
+ p._build_client_connection("sat1", "sat1", "sat1")
245
+ first_conn = p._peers["sat1"]
246
+ # Simulating message from known peer — we do NOT call _build again.
247
+ assert p._peers["sat1"] is first_conn
248
+
249
+ def test_invalid_key_not_added(self):
250
+ p = _make_protocol()
251
+ p.hm_protocol.db.get_client_by_api_key.return_value = None
252
+ conn = p._build_client_connection("bad", "bad", "bad")
253
+ assert conn is None
254
+ assert "bad" not in p._peers
255
+
256
+ def test_handle_new_client_called(self):
257
+ p = _make_protocol()
258
+ p._build_client_connection("sat1", "sat1", "sat1")
259
+ p.hm_protocol.handle_new_client.assert_called_once()
260
+
261
+ def test_invalid_key_triggers_invalid_key_handler(self):
262
+ p = _make_protocol()
263
+ p.hm_protocol.db.get_client_by_api_key.return_value = None
264
+ p._build_client_connection("bad", "bad", "bad")
265
+ p.hm_protocol.handle_invalid_key_connected.assert_called_once()
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # send_msg publishes to the correct topic
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ class TestSendMsg:
274
+ def test_send_msg_publishes_to_s2c(self):
275
+ p = _make_protocol()
276
+ p._build_client_connection("sat1", "sat1", "sat1")
277
+ conn = p._peers["sat1"]
278
+
279
+ payload = b"encrypted-frame"
280
+ conn.send_msg(payload, False)
281
+
282
+ expected_topic = p.s2c_topic("sat1")
283
+ p._mqtt.publish.assert_called_with(expected_topic, payload, qos=1)
284
+
285
+ def test_send_msg_bin_uses_qos0(self):
286
+ p = _make_protocol()
287
+ p._build_client_connection("sat1", "sat1", "sat1")
288
+ conn = p._peers["sat1"]
289
+
290
+ conn.send_msg(b"audio", True)
291
+
292
+ # QoS 0 for binary.
293
+ call_args = p._mqtt.publish.call_args
294
+ assert call_args[1]["qos"] == 0 or (len(call_args[0]) >= 3 and call_args[0][2] == 0)
295
+
296
+ def test_send_msg_str_payload_encoded(self):
297
+ p = _make_protocol()
298
+ p._build_client_connection("sat1", "sat1", "sat1")
299
+ conn = p._peers["sat1"]
300
+
301
+ conn.send_msg("string-payload", False)
302
+
303
+ topic = p.s2c_topic("sat1")
304
+ p._mqtt.publish.assert_called_with(topic, b"string-payload", qos=1)
305
+
306
+
307
+ # ---------------------------------------------------------------------------
308
+ # Disconnect: do_disconnect and _disconnect_peer
309
+ # ---------------------------------------------------------------------------
310
+
311
+
312
+ class TestDisconnect:
313
+ def test_disconnect_removes_from_map(self):
314
+ p = _make_protocol()
315
+ p._build_client_connection("sat1", "sat1", "sat1")
316
+ assert "sat1" in p._peers
317
+
318
+ p._disconnect_peer("sat1")
319
+
320
+ assert "sat1" not in p._peers
321
+
322
+ def test_disconnect_calls_handle_client_disconnected(self):
323
+ p = _make_protocol()
324
+ p._build_client_connection("sat1", "sat1", "sat1")
325
+ p.hm_protocol.handle_client_disconnected.reset_mock()
326
+
327
+ p._disconnect_peer("sat1")
328
+
329
+ p.hm_protocol.handle_client_disconnected.assert_called_once()
330
+
331
+ def test_do_disconnect_publishes_tombstone(self):
332
+ p = _make_protocol()
333
+ p._build_client_connection("sat1", "sat1", "sat1")
334
+ conn = p._peers["sat1"]
335
+ p._mqtt.publish.reset_mock()
336
+
337
+ conn.disconnect()
338
+
339
+ status_topic = p.status_topic("sat1")
340
+ p._mqtt.publish.assert_called_with(status_topic, "offline", qos=1, retain=True)
341
+ assert "sat1" not in p._peers
342
+
343
+ def test_disconnect_unknown_peer_is_noop(self):
344
+ p = _make_protocol()
345
+ # Should not raise.
346
+ p._disconnect_peer("ghost")
347
+ p.hm_protocol.handle_client_disconnected.assert_not_called()
348
+
349
+
350
+ # ---------------------------------------------------------------------------
351
+ # LWT / status message handling
352
+ # ---------------------------------------------------------------------------
353
+
354
+
355
+ class TestLWT:
356
+ def _make_msg(self, topic: str, payload: bytes) -> MagicMock:
357
+ m = MagicMock()
358
+ m.topic = topic
359
+ m.payload = payload
360
+ return m
361
+
362
+ def test_lwt_offline_triggers_disconnect(self):
363
+ p = _make_protocol()
364
+ p._build_client_connection("sat1", "sat1", "sat1")
365
+ p.hm_protocol.handle_client_disconnected.reset_mock()
366
+
367
+ msg = self._make_msg("hivemind/testhub/status/sat1", b"offline")
368
+ p._on_message(p._mqtt, None, msg)
369
+
370
+ p.hm_protocol.handle_client_disconnected.assert_called_once()
371
+ assert "sat1" not in p._peers
372
+
373
+ def test_lwt_online_ignored(self):
374
+ p = _make_protocol()
375
+ p._build_client_connection("sat1", "sat1", "sat1")
376
+ p.hm_protocol.handle_client_disconnected.reset_mock()
377
+
378
+ msg = self._make_msg("hivemind/testhub/status/sat1", b"online")
379
+ p._on_message(p._mqtt, None, msg)
380
+
381
+ p.hm_protocol.handle_client_disconnected.assert_not_called()
382
+
383
+ def test_c2s_message_routed_to_handle_message(self):
384
+ p = _make_protocol()
385
+ p._build_client_connection("sat1", "sat1", "sat1")
386
+ p.hm_protocol.handle_message.reset_mock()
387
+
388
+ msg = self._make_msg("hivemind/testhub/c2s/sat1", b"payload")
389
+ p._on_message(p._mqtt, None, msg)
390
+
391
+ p.hm_protocol.handle_message.assert_called_once()
392
+
393
+ def test_unknown_peer_auto_registered_on_c2s(self):
394
+ p = _make_protocol()
395
+ assert "newsat" not in p._peers
396
+
397
+ msg = self._make_msg("hivemind/testhub/c2s/newsat", b"payload")
398
+ p._on_message(p._mqtt, None, msg)
399
+
400
+ assert "newsat" in p._peers
401
+
402
+ def test_bad_topic_shape_ignored(self):
403
+ p = _make_protocol()
404
+ msg = self._make_msg("bad", b"x")
405
+ p._on_message(p._mqtt, None, msg)
406
+ p.hm_protocol.handle_message.assert_not_called()
407
+
408
+
409
+ # ---------------------------------------------------------------------------
410
+ # Idle-timeout sweep
411
+ # ---------------------------------------------------------------------------
412
+
413
+
414
+ class TestIdleSweep:
415
+ def test_stale_peer_evicted(self):
416
+ p = _make_protocol()
417
+ p._build_client_connection("sat1", "sat1", "sat1")
418
+ # Back-date last_seen to force eviction.
419
+ p._last_seen["sat1"] = time.monotonic() - 9999
420
+
421
+ p.hm_protocol.handle_client_disconnected.reset_mock()
422
+ p._idle_sweep.__func__ # confirm it's a method
423
+
424
+ # Run one cycle manually by calling _disconnect_peer for stale peers.
425
+ now = time.monotonic()
426
+ idle_timeout = 300
427
+ stale = [sid for sid, ts in p._last_seen.items() if (now - ts) > idle_timeout]
428
+ for sid in stale:
429
+ p._disconnect_peer(sid)
430
+
431
+ assert "sat1" not in p._peers
432
+ p.hm_protocol.handle_client_disconnected.assert_called_once()
433
+
434
+
435
+ # ---------------------------------------------------------------------------
436
+ # QoS helpers
437
+ # ---------------------------------------------------------------------------
438
+
439
+
440
+ class TestQoS:
441
+ def test_default_qos_is_1(self):
442
+ p = _make_protocol()
443
+ assert p._qos(False) == 1
444
+
445
+ def test_binary_qos_is_0(self):
446
+ p = _make_protocol()
447
+ assert p._qos(True) == 0
448
+
449
+ def test_custom_qos_from_config(self):
450
+ p = _make_protocol({"qos": 0})
451
+ assert p._qos(False) == 0