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.
- hivemind_mqtt_protocol-0.1.0a2/PKG-INFO +18 -0
- hivemind_mqtt_protocol-0.1.0a2/README.md +126 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol/__init__.py +389 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol/version.py +8 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/PKG-INFO +18 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/SOURCES.txt +11 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/dependency_links.txt +1 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/entry_points.txt +2 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/requires.txt +11 -0
- hivemind_mqtt_protocol-0.1.0a2/hivemind_mqtt_protocol.egg-info/top_level.txt +1 -0
- hivemind_mqtt_protocol-0.1.0a2/pyproject.toml +41 -0
- hivemind_mqtt_protocol-0.1.0a2/setup.cfg +4 -0
- hivemind_mqtt_protocol-0.1.0a2/tests/test_mqtt_protocol.py +451 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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
|