diff --git a/app/src/components/MeshPanel.tsx b/app/src/components/MeshPanel.tsx index 516d09e..cab9885 100644 --- a/app/src/components/MeshPanel.tsx +++ b/app/src/components/MeshPanel.tsx @@ -19,25 +19,23 @@ export function MeshPanel({ mesh }: MeshPanelProps) { - {mesh.mqtt_connected ? ( + {mesh.connected ? ( ) : ( )} - {mesh.mqtt_connected ? "connected" : "disconnected"} + {mesh.connected ? "connected" : "disconnected"}
- {mesh.mqtt_broker_host && ( - - mqtt://{mesh.mqtt_broker_host}:{mesh.mqtt_broker_port} - + {mesh.nats_url && ( + {mesh.nats_url} )} {mesh.mdns_enabled && ( mDNS active diff --git a/app/src/types/index.ts b/app/src/types/index.ts index 8b6fc4a..915d51d 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -325,9 +325,8 @@ export interface NodeDetail extends NodeSummary { export interface MeshStatus { enabled: boolean - mqtt_connected: boolean - mqtt_broker_host: string | null - mqtt_broker_port: number | null + connected: boolean + nats_url: string | null mdns_enabled: boolean peer_count: number peers: string[] diff --git a/castle-api/pyproject.toml b/castle-api/pyproject.toml index aff58e3..4d3591e 100644 --- a/castle-api/pyproject.toml +++ b/castle-api/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "pydantic-settings>=2.0.0", "httpx>=0.27.0", "castle-core", - "paho-mqtt>=2.0.0", + "nats-py>=2.9.0", "zeroconf>=0.131.0", ] diff --git a/castle-api/src/castle_api/config.py b/castle-api/src/castle_api/config.py index d641ad2..e85a8b0 100644 --- a/castle-api/src/castle_api/config.py +++ b/castle-api/src/castle_api/config.py @@ -15,9 +15,8 @@ class Settings(BaseSettings): port: int = 9020 # Mesh coordination (all off by default — single-node works without them) - mqtt_enabled: bool = False - mqtt_host: str = "localhost" - mqtt_port: int = 1883 + nats_enabled: bool = False + nats_url: str = "nats://localhost:4222" mdns_enabled: bool = False model_config = { diff --git a/castle-api/src/castle_api/main.py b/castle-api/src/castle_api/main.py index 7a71039..f291f39 100644 --- a/castle-api/src/castle_api/main.py +++ b/castle-api/src/castle_api/main.py @@ -56,25 +56,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: poll_task = asyncio.create_task(health_poll_loop()) # --- Mesh coordination (opt-in) --- - mqtt_client = None + nats_client = None mdns_service = None - if settings.mqtt_enabled: + if settings.nats_enabled: try: - from castle_api.mqtt_client import CastleMQTTClient + from castle_api.nats_client import CastleNATSClient registry = get_registry() - mqtt_client = CastleMQTTClient( + nats_client = CastleNATSClient( local_hostname=registry.node.hostname, local_registry=registry, - broker_host=settings.mqtt_host, - broker_port=settings.mqtt_port, + servers=settings.nats_url, ) - await mqtt_client.start() - app.state.mqtt_client = mqtt_client + await nats_client.start() + app.state.nats_client = nats_client except Exception: - logger.exception("Failed to start MQTT client") - mqtt_client = None + logger.exception("Failed to start NATS mesh client") + nats_client = None if settings.mdns_enabled: try: @@ -97,8 +96,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: _shutting_down = True poll_task.cancel() - if mqtt_client: - await mqtt_client.stop() + if nats_client: + await nats_client.stop() if mdns_service: mdns_service.stop() diff --git a/castle-api/src/castle_api/mdns.py b/castle-api/src/castle_api/mdns.py index 6205315..844cbaa 100644 --- a/castle-api/src/castle_api/mdns.py +++ b/castle-api/src/castle_api/mdns.py @@ -1,8 +1,7 @@ -"""mDNS discovery for castle mesh — zero-config LAN peer and broker discovery. +"""mDNS discovery for castle mesh — zero-config LAN peer discovery. -Advertises this node as _castle._tcp and browses for: - - _castle._tcp — peer castle nodes - - _mqtt._tcp — MQTT broker address +Advertises this node as _castle._tcp and browses for _castle._tcp peers. This is +the on-LAN convenience path; cross-network meshes use explicit NATS seed URLs. """ from __future__ import annotations @@ -15,11 +14,10 @@ from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf logger = logging.getLogger(__name__) CASTLE_SERVICE_TYPE = "_castle._tcp.local." -MQTT_SERVICE_TYPE = "_mqtt._tcp.local." class CastleMDNS: - """Advertise this castle node and discover peers + MQTT broker via mDNS.""" + """Advertise this castle node and discover peers via mDNS.""" def __init__( self, @@ -38,7 +36,6 @@ class CastleMDNS: self.peers: dict[ str, dict ] = {} # hostname -> {gateway_port, api_port, addresses} - self.mqtt_broker: dict | None = None # {host, port} or None def _on_service_state_change( self, @@ -55,8 +52,6 @@ class CastleMDNS: if service_type == CASTLE_SERVICE_TYPE: self._handle_castle_peer(info) - elif service_type == MQTT_SERVICE_TYPE: - self._handle_mqtt_broker(info) elif state_change == ServiceStateChange.Removed: if service_type == CASTLE_SERVICE_TYPE: @@ -89,20 +84,6 @@ class CastleMDNS: } logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses) - def _handle_mqtt_broker(self, info: ServiceInfo) -> None: - """Process a discovered MQTT broker.""" - addresses = [ - socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4 - ] - if addresses: - self.mqtt_broker = { - "host": addresses[0], - "port": info.port, - } - logger.info( - "mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port - ) - def start(self) -> None: """Start advertising and browsing.""" self._zeroconf = Zeroconf() @@ -123,7 +104,7 @@ class CastleMDNS: "mDNS: advertising %s on port %d", self._hostname, self._gateway_port ) - # Browse for peers and MQTT broker + # Browse for peer castle nodes self._browsers.append( ServiceBrowser( self._zeroconf, @@ -131,13 +112,6 @@ class CastleMDNS: handlers=[self._on_service_state_change], ) ) - self._browsers.append( - ServiceBrowser( - self._zeroconf, - MQTT_SERVICE_TYPE, - handlers=[self._on_service_state_change], - ) - ) def stop(self) -> None: """Stop advertising and close.""" diff --git a/castle-api/src/castle_api/mesh_wire.py b/castle-api/src/castle_api/mesh_wire.py new file mode 100644 index 0000000..9f66509 --- /dev/null +++ b/castle-api/src/castle_api/mesh_wire.py @@ -0,0 +1,99 @@ +"""Mesh wire format — (de)serialize a NodeRegistry for cross-node transport. + +Transport-agnostic (no MQTT/NATS imports). Only the fields needed for mesh +routing are included; env vars, run_cmd, and castle_root are **excluded** to +avoid leaking secrets — this invariant is load-bearing and must be preserved by +any transport that carries this payload. +""" + +from __future__ import annotations + +import json + +from castle_core.registry import ( + Deployment, + NodeConfig, + NodeRegistry, +) + + +def registry_to_json(registry: NodeRegistry) -> str: + """Serialize a NodeRegistry to JSON (secret-stripped).""" + data: dict = { + "node": { + "hostname": registry.node.hostname, + "gateway_port": registry.node.gateway_port, + # acme domain — lets peers build launch URLs (.) + # for this node's exposed apps. Omitted when the node has no domain. + "gateway_domain": registry.node.gateway_domain, + }, + "deployed": {}, + } + + for _kind, name, comp in registry.all(): + entry: dict = { + "manager": comp.manager, + "launcher": comp.launcher, + "kind": comp.kind, + } + if comp.stack: + entry["stack"] = comp.stack + if comp.description: + entry["description"] = comp.description + if comp.port is not None: + entry["port"] = comp.port + if comp.health_path: + entry["health_path"] = comp.health_path + if comp.subdomain: + entry["subdomain"] = comp.subdomain + if comp.schedule: + entry["schedule"] = comp.schedule + if comp.managed: + entry["managed"] = comp.managed + # Socket surface + external target — so a peer can resolve cross-node + # consumption endpoints (still no secrets: only ports/URLs). + if getattr(comp, "tcp_port", None) is not None: + entry["tcp_port"] = comp.tcp_port + if getattr(comp, "base_url", None): + entry["base_url"] = comp.base_url + # requires — deployment refs (no secrets), so peers can draw cross-node deps. + if getattr(comp, "requires", None): + entry["requires"] = comp.requires + data["deployed"][NodeRegistry.key(comp.kind, name)] = entry + + return json.dumps(data) + + +def json_to_registry(payload: str) -> NodeRegistry: + """Deserialize a NodeRegistry from a JSON payload.""" + data = json.loads(payload) + node_data = data.get("node", {}) + node = NodeConfig( + hostname=node_data.get("hostname", ""), + castle_root=node_data.get("castle_root"), + gateway_port=node_data.get("gateway_port", 9000), + gateway_domain=node_data.get("gateway_domain"), + ) + deployed: dict[str, Deployment] = {} + for key, comp_data in data.get("deployed", {}).items(): + key_kind, name = key.split("/", 1) if "/" in key else (None, key) + kind = comp_data.get("kind") or key_kind or "service" + deployed[NodeRegistry.key(kind, name)] = Deployment( + manager=comp_data.get("manager", "systemd"), + launcher=comp_data.get("launcher"), + run_cmd=comp_data.get("run_cmd", []), + env=comp_data.get("env", {}), + description=comp_data.get("description"), + name=name, + kind=kind, + stack=comp_data.get("stack"), + port=comp_data.get("port"), + health_path=comp_data.get("health_path"), + subdomain=comp_data.get("subdomain"), + schedule=comp_data.get("schedule"), + managed=comp_data.get("managed", False), + tcp_port=comp_data.get("tcp_port"), + base_url=comp_data.get("base_url"), + requires=comp_data.get("requires", []), + ) + return NodeRegistry(node=node, deployed=deployed) diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index d2459c3..d7fce04 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -218,9 +218,8 @@ class MeshStatus(BaseModel): """Current state of the mesh coordination layer.""" enabled: bool = False - mqtt_connected: bool = False - mqtt_broker_host: str | None = None - mqtt_broker_port: int | None = None + connected: bool = False + nats_url: str | None = None mdns_enabled: bool = False peer_count: int = 0 peers: list[str] = [] diff --git a/castle-api/src/castle_api/nats_client.py b/castle-api/src/castle_api/nats_client.py new file mode 100644 index 0000000..0194058 --- /dev/null +++ b/castle-api/src/castle_api/nats_client.py @@ -0,0 +1,184 @@ +"""NATS JetStream client for inter-node mesh coordination. + +Replaces the MQTT transport. State lives in a JetStream **KV bucket** rather than +retained MQTT messages: + + castle-registry — key=, value=secret-stripped NodeRegistry JSON + +Lifecycle: + * on connect: ensure the bucket, PUT our registry, seed local state from every + existing key, then watch the bucket for peer changes. + * heartbeat: re-PUT our registry every HEARTBEAT_SEC so peers refresh their + last-seen clock (crash liveness rides the existing stale-TTL). + * graceful stop: DELETE our key → peers get an immediate offline signal. + +Being asyncio-native, the watch callback calls ``broadcast`` directly — no +cross-thread ``run_coroutine_threadsafe`` hop (which the paho client needed). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging + +import nats +from nats.js.api import KeyValueConfig + +from castle_core.registry import NodeRegistry + +from castle_api.mesh import mesh_state +from castle_api.mesh_wire import json_to_registry, registry_to_json +from castle_api.stream import broadcast + +logger = logging.getLogger(__name__) + +REGISTRY_BUCKET = "castle-registry" +HEARTBEAT_SEC = 30.0 +PRUNE_SEC = 30.0 + + +class CastleNATSClient: + """Async NATS/JetStream mesh client.""" + + def __init__( + self, + local_hostname: str, + local_registry: NodeRegistry, + servers: str | list[str] = "nats://localhost:4222", + ) -> None: + self._local_hostname = local_hostname + self._local_registry = local_registry + self._servers = servers + self._nc: nats.NATS | None = None + self._kv = None + self._tasks: list[asyncio.Task] = [] + self._last_json: dict[str, str] = {} + self._online: set[str] = set() + + @property + def connected(self) -> bool: + return self._nc is not None and self._nc.is_connected + + @property + def servers(self) -> str | list[str]: + return self._servers + + async def start(self) -> None: + """Connect, publish our registry, seed state, and start watchers.""" + self._nc = await nats.connect( + self._servers, + name=f"castle-{self._local_hostname}", + max_reconnect_attempts=-1, # reconnect forever — nodes come and go + ) + js = self._nc.jetstream() + try: + self._kv = await js.key_value(REGISTRY_BUCKET) + except Exception: + self._kv = await js.create_key_value( + config=KeyValueConfig(bucket=REGISTRY_BUCKET, history=1) + ) + + await self.publish_registry(self._local_registry) + await self._seed_existing() + + self._tasks = [ + asyncio.create_task(self._watch_loop()), + asyncio.create_task(self._heartbeat_loop()), + asyncio.create_task(self._prune_loop()), + ] + logger.info("NATS mesh client started (servers=%s)", self._servers) + + async def stop(self) -> None: + """Delete our key (immediate offline to peers) and disconnect.""" + for t in self._tasks: + t.cancel() + for t in self._tasks: + with contextlib.suppress(asyncio.CancelledError, Exception): + await t + self._tasks = [] + if self._kv is not None: + with contextlib.suppress(Exception): + await self._kv.delete(self._local_hostname) + if self._nc is not None: + with contextlib.suppress(Exception): + await self._nc.drain() + self._nc = None + logger.info("NATS mesh client stopped") + + async def publish_registry(self, registry: NodeRegistry) -> None: + """PUT (or refresh) our local registry into the KV bucket.""" + self._local_registry = registry + if self._kv is None: + return + await self._kv.put( + self._local_hostname, registry_to_json(registry).encode() + ) + + async def _seed_existing(self) -> None: + """Load every peer key already present in the bucket.""" + if self._kv is None: + return + try: + keys = await self._kv.keys() + except Exception: + keys = [] # empty bucket raises NoKeysError in nats-py + for key in keys: + if key == self._local_hostname: + continue + with contextlib.suppress(Exception): + entry = await self._kv.get(key) + if entry.value: + self._apply_put(key, entry.value.decode()) + + async def _watch_loop(self) -> None: + assert self._kv is not None + watcher = await self._kv.watchall() + async for entry in watcher: + if entry is None: # "caught up with current values" sentinel + continue + key = entry.key + if key == self._local_hostname: + continue + try: + if entry.operation in ("DEL", "PURGE"): + await self._apply_delete(key) + elif entry.value: + changed = self._apply_put(key, entry.value.decode()) + if changed: + await broadcast( + "mesh", {"event": "node_updated", "hostname": key} + ) + except Exception: + logger.exception("Error handling mesh entry for %s", key) + + def _apply_put(self, hostname: str, payload: str) -> bool: + """Update mesh state from a peer PUT. Returns True if content changed.""" + registry = json_to_registry(payload) + mesh_state.update_node(hostname, registry) # always refresh last-seen + self._online.add(hostname) + changed = self._last_json.get(hostname) != payload + self._last_json[hostname] = payload + return changed + + async def _apply_delete(self, hostname: str) -> None: + mesh_state.set_offline(hostname) + self._online.discard(hostname) + self._last_json.pop(hostname, None) + await broadcast("mesh", {"event": "node_offline", "hostname": hostname}) + + async def _heartbeat_loop(self) -> None: + while True: + await asyncio.sleep(HEARTBEAT_SEC) + with contextlib.suppress(Exception): + await self.publish_registry(self._local_registry) + + async def _prune_loop(self) -> None: + """Mark crashed peers (no refresh within the stale TTL) offline.""" + while True: + await asyncio.sleep(PRUNE_SEC) + all_nodes = mesh_state.all_nodes(include_stale=True) + for host in list(self._online): + node = all_nodes.get(host) + if node is None or node.is_stale: + await self._apply_delete(host) diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index cd5851c..9fd31da 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -66,15 +66,14 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu @router.get("/mesh/status", response_model=MeshStatus) def get_mesh_status(request: Request) -> MeshStatus: """Get the current state of the mesh coordination layer.""" - mqtt_client = getattr(request.app.state, "mqtt_client", None) + nats_client = getattr(request.app.state, "nats_client", None) peers = list(mesh_state.all_nodes(include_stale=True).keys()) return MeshStatus( - enabled=settings.mqtt_enabled, - mqtt_connected=mqtt_client.connected if mqtt_client else False, - mqtt_broker_host=mqtt_client.broker_host if mqtt_client else None, - mqtt_broker_port=mqtt_client.broker_port if mqtt_client else None, + enabled=settings.nats_enabled, + connected=nats_client.connected if nats_client else False, + nats_url=str(nats_client.servers) if nats_client else None, mdns_enabled=settings.mdns_enabled, peer_count=len(peers), peers=peers, diff --git a/castle-api/tests/test_mesh_wire.py b/castle-api/tests/test_mesh_wire.py new file mode 100644 index 0000000..1467b69 --- /dev/null +++ b/castle-api/tests/test_mesh_wire.py @@ -0,0 +1,110 @@ +"""Tests for the transport-agnostic mesh wire format (registry (de)serialization).""" + +import json + +from castle_core.registry import Deployment, NodeConfig, NodeRegistry + +from castle_api.mesh_wire import json_to_registry, registry_to_json + + +def _make_registry() -> NodeRegistry: + return NodeRegistry( + node=NodeConfig( + hostname="tower", castle_root="/data/repos/castle", gateway_port=9000 + ), + deployed={ + "my-svc": Deployment( + manager="systemd", + launcher="python", + run_cmd=["uv", "run", "my-svc"], + env={"PORT": "9001", "SECRET_KEY": "super-secret"}, + description="My service", + name="my-svc", + kind="service", + stack="python-fastapi", + port=9001, + health_path="/health", + subdomain="my-svc", + managed=True, + ), + "my-job": Deployment( + manager="systemd", + launcher="command", + run_cmd=["my-job"], + name="my-job", + kind="job", + stack="python-cli", + schedule="0 2 * * *", + ), + }, + ) + + +class TestRegistrySerialization: + """Round-trip serialization of NodeRegistry to/from JSON.""" + + def test_round_trip(self) -> None: + original = _make_registry() + json_str = registry_to_json(original) + restored = json_to_registry(json_str) + + assert restored.node.hostname == "tower" + assert restored.node.gateway_port == 9000 + + def test_deployed_components_preserved(self) -> None: + original = _make_registry() + restored = json_to_registry(registry_to_json(original)) + + svc = restored.get("service", "my-svc") + assert svc is not None + assert svc.manager == "systemd" + assert svc.launcher == "python" + assert svc.port == 9001 + assert svc.health_path == "/health" + assert svc.subdomain == "my-svc" + assert svc.managed is True + assert svc.kind == "service" + assert svc.stack == "python-fastapi" + + def test_job_fields_preserved(self) -> None: + original = _make_registry() + restored = json_to_registry(registry_to_json(original)) + + job = restored.get("job", "my-job") + assert job is not None + assert job.launcher == "command" + assert job.schedule == "0 2 * * *" + assert job.kind == "job" + assert job.stack == "python-cli" + + def test_optional_fields_omitted(self) -> None: + """Fields like port, health_path are None when not set.""" + reg = NodeRegistry( + node=NodeConfig(hostname="minimal"), + deployed={ + "bare": Deployment( + manager="systemd", launcher="command", run_cmd=["bare"], name="bare" + ), + }, + ) + restored = json_to_registry(registry_to_json(reg)) + bare = restored.get("service", "bare") + assert bare.port is None + assert bare.health_path is None + assert bare.subdomain is None + assert bare.schedule is None + assert bare.managed is False + + def test_no_secrets_in_payload(self) -> None: + """env vars, run_cmd, and castle_root must never appear on the wire.""" + original = _make_registry() + json_str = registry_to_json(original) + data = json.loads(json_str) + + # No castle_root in node + assert "castle_root" not in data["node"] + + # No env or run_cmd in any component + for name, comp in data["deployed"].items(): + assert "env" not in comp, f"{name} has env in payload" + assert "run_cmd" not in comp, f"{name} has run_cmd in payload" diff --git a/uv.lock b/uv.lock index fb1eaf0..ecaf328 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ dependencies = [ { name = "castle-core" }, { name = "fastapi" }, { name = "httpx" }, - { name = "paho-mqtt" }, + { name = "nats-py" }, { name = "pydantic-settings" }, { name = "uvicorn", extra = ["standard"] }, { name = "zeroconf" }, @@ -67,7 +67,7 @@ requires-dist = [ { name = "castle-core", editable = "core" }, { name = "fastapi", specifier = ">=0.115.0" }, { name = "httpx", specifier = ">=0.27.0" }, - { name = "paho-mqtt", specifier = ">=2.0.0" }, + { name = "nats-py", specifier = ">=2.9.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "zeroconf", specifier = ">=0.131.0" }, @@ -349,6 +349,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "nats-py" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/f0/fc5e93f2b0dd14a202590ad9d30eda1955ea872039b5204357348d0f4b1e/nats_py-2.15.0.tar.gz", hash = "sha256:6622c547d9a7d2313d9c147d46c386188f4ec2c7b5c9f9a0438a4d1b55f54a93", size = 75995, upload-time = "2026-06-05T07:34:03.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/a8/b55606c7c621fb813c8ec78baf201d2c78bf6051091ec0c7ada572999e95/nats_py-2.15.0-py3-none-any.whl", hash = "sha256:9f8d36aa52a9926a88b8f1d70cf1fdce0ad387941479b500ee9ab3e51073cefd", size = 90334, upload-time = "2026-06-05T07:34:02.81Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -367,15 +376,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] -[[package]] -name = "paho-mqtt" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" }, -] - [[package]] name = "pluggy" version = "1.6.0"