enigmind-agent 0.1.0__py3-none-any.whl

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,16 @@
1
+ from .agent import Agent
2
+ from .claim import ClaimSettings, claim_device, generate_claim_token, hardware_fingerprint
3
+ from .config import AgentConfig, ClaimConfig, TLSConfig
4
+ from .version import __version__
5
+
6
+ __all__ = [
7
+ "Agent",
8
+ "AgentConfig",
9
+ "ClaimConfig",
10
+ "ClaimSettings",
11
+ "TLSConfig",
12
+ "__version__",
13
+ "claim_device",
14
+ "generate_claim_token",
15
+ "hardware_fingerprint",
16
+ ]
@@ -0,0 +1,162 @@
1
+ import threading
2
+ import time
3
+ from typing import Any, Callable, Dict, Iterable, Optional
4
+
5
+ from .buffer import SQLiteTelemetryBuffer
6
+ from .claim import ClaimError, ClaimSettings, claim_device
7
+ from .config import AgentConfig
8
+ from .delta import DeltaFilter
9
+ from .mqtt import MQTTClient
10
+ from .rpc import RpcRouter
11
+ from .plugins.base import Plugin
12
+
13
+
14
+ class Agent:
15
+ def __init__(self, config: AgentConfig) -> None:
16
+ self.config = config.normalized()
17
+ self._buffer = SQLiteTelemetryBuffer(self.config.buffer_path)
18
+ self._delta = DeltaFilter()
19
+ self._rpc = RpcRouter()
20
+ self._plugins: Iterable[Plugin] = []
21
+ self._mqtt = MQTTClient(self.config, on_message=self._on_message, on_connect=self._on_connect)
22
+ self._heartbeat_thread: Optional[threading.Thread] = None
23
+ self._stop_event = threading.Event()
24
+
25
+ def register_plugin(self, plugin: Plugin) -> None:
26
+ self._plugins = [*self._plugins, plugin]
27
+
28
+ def register_rpc_handler(self, method: str, handler: Callable[[Any], Any]) -> None:
29
+ self._rpc.register(method, handler)
30
+
31
+ def start(self) -> None:
32
+ self._start_time = time.time()
33
+ for plugin in self._plugins:
34
+ plugin.on_start(self)
35
+ self._ensure_claimed()
36
+ self._mqtt.connect()
37
+ self._mqtt.subscribe(self.config.rpc_request_topic)
38
+ self._stop_event.clear()
39
+ self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop, daemon=True)
40
+ self._heartbeat_thread.start()
41
+
42
+ def stop(self) -> None:
43
+ self._stop_event.set()
44
+ if self._heartbeat_thread:
45
+ self._heartbeat_thread.join(timeout=2)
46
+ for plugin in self._plugins:
47
+ plugin.on_stop(self)
48
+ self._mqtt.disconnect()
49
+
50
+ def send_telemetry(self, payload: Dict[str, Any], qos: int = 0, retain: bool = False, force: bool = False) -> None:
51
+ if not force and self.config.delta_sync:
52
+ payload, changed = self._delta.diff(payload)
53
+ if not changed:
54
+ return
55
+ topic = self.config.telemetry_topic
56
+ if not self._mqtt.connected:
57
+ self._buffer.enqueue(topic, payload, qos=qos, retain=retain)
58
+ return
59
+ ok = self._mqtt.publish(topic, payload, qos=qos, retain=retain)
60
+ if not ok:
61
+ self._buffer.enqueue(topic, payload, qos=qos, retain=retain)
62
+
63
+ def flush_buffer(self, batch_size: int = 100) -> None:
64
+ if not self._mqtt.connected:
65
+ return
66
+ messages = self._buffer.dequeue_batch(limit=batch_size)
67
+ if not messages:
68
+ return
69
+ failed_ids = []
70
+ for message in messages:
71
+ ok = self._mqtt.publish(message.topic, message.payload, qos=message.qos, retain=message.retain)
72
+ if not ok:
73
+ failed_ids.append(message.id)
74
+ success_ids = [msg.id for msg in messages if msg.id not in failed_ids]
75
+ self._buffer.delete(success_ids)
76
+
77
+ def buffer_count(self) -> int:
78
+ return self._buffer.count()
79
+
80
+ def _heartbeat_loop(self) -> None:
81
+ last_flush = 0.0
82
+ while not self._stop_event.is_set():
83
+ now = time.time()
84
+ payload = {
85
+ "type": "heartbeat",
86
+ "ts": int(now * 1000),
87
+ "uptime_s": int(now - (self._start_time if hasattr(self, "_start_time") else now)),
88
+ }
89
+ for plugin in self._plugins:
90
+ metrics = plugin.collect_telemetry()
91
+ if metrics:
92
+ payload.update(metrics)
93
+ self.send_telemetry(payload, force=True)
94
+ if now - last_flush > 10:
95
+ self.flush_buffer()
96
+ last_flush = now
97
+ self._stop_event.wait(self.config.heartbeat_interval_s)
98
+
99
+ def _on_message(self, topic: str, payload: bytes) -> None:
100
+ prefix = self.config.rpc_request_topic.rstrip("+")
101
+ if topic.startswith(prefix):
102
+ request_id = topic.split("/")[-1]
103
+ response = self._rpc.handle(payload)
104
+ if response is None:
105
+ return
106
+ response_topic = self.config.rpc_response_topic.format(request_id=request_id)
107
+ self._mqtt.publish(response_topic, response, qos=0)
108
+
109
+ def _on_connect(self) -> None:
110
+ self.flush_buffer()
111
+
112
+ def _ensure_claimed(self) -> None:
113
+ if self.config.has_credentials():
114
+ return
115
+ claim_cfg = self.config.claim
116
+ if not claim_cfg.base_url:
117
+ raise RuntimeError("Missing credentials and claim.base_url is not configured")
118
+ settings = ClaimSettings(
119
+ base_url=claim_cfg.base_url,
120
+ initiate_path=claim_cfg.initiate_path,
121
+ status_path=claim_cfg.status_path,
122
+ poll_interval_s=claim_cfg.poll_interval_s,
123
+ timeout_s=claim_cfg.timeout_s,
124
+ request_timeout_s=claim_cfg.request_timeout_s,
125
+ max_retries=claim_cfg.max_retries,
126
+ backoff_s=claim_cfg.backoff_s,
127
+ state_path=claim_cfg.state_path,
128
+ )
129
+ try:
130
+ credentials = claim_device(settings)
131
+ except ClaimError as exc:
132
+ raise RuntimeError(f"Device claim failed: {exc}") from exc
133
+ self._apply_claimed_credentials(credentials)
134
+ self.config.normalized()
135
+ self._mqtt = MQTTClient(self.config, on_message=self._on_message, on_connect=self._on_connect)
136
+
137
+ def _apply_claimed_credentials(self, credentials: Dict[str, Any]) -> None:
138
+ mapping = (
139
+ "broker_url",
140
+ "device_id",
141
+ "api_key",
142
+ "username",
143
+ "password",
144
+ "client_id",
145
+ "telemetry_topic",
146
+ "heartbeat_topic",
147
+ "rpc_request_topic",
148
+ "rpc_response_topic",
149
+ "keepalive",
150
+ )
151
+ for key in mapping:
152
+ if key not in credentials:
153
+ continue
154
+ value = credentials.get(key)
155
+ if value is None:
156
+ continue
157
+ if key == "keepalive":
158
+ try:
159
+ value = int(value)
160
+ except (TypeError, ValueError):
161
+ continue
162
+ setattr(self.config, key, value)
@@ -0,0 +1,80 @@
1
+ import json
2
+ import os
3
+ import sqlite3
4
+ import threading
5
+ from dataclasses import dataclass
6
+ from typing import Iterable, List, Optional
7
+
8
+
9
+ @dataclass
10
+ class BufferedMessage:
11
+ id: int
12
+ topic: str
13
+ payload: dict
14
+ qos: int
15
+ retain: bool
16
+
17
+
18
+ class SQLiteTelemetryBuffer:
19
+ def __init__(self, path: str) -> None:
20
+ self.path = path
21
+ self._lock = threading.Lock()
22
+ os.makedirs(os.path.dirname(path), exist_ok=True)
23
+ self._init_db()
24
+
25
+ def _connect(self) -> sqlite3.Connection:
26
+ conn = sqlite3.connect(self.path)
27
+ conn.execute("PRAGMA journal_mode=WAL;")
28
+ conn.execute("PRAGMA synchronous=NORMAL;")
29
+ return conn
30
+
31
+ def _init_db(self) -> None:
32
+ with self._connect() as conn:
33
+ conn.execute(
34
+ """
35
+ CREATE TABLE IF NOT EXISTS telemetry_queue (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ topic TEXT NOT NULL,
38
+ payload TEXT NOT NULL,
39
+ qos INTEGER NOT NULL,
40
+ retain INTEGER NOT NULL
41
+ )
42
+ """
43
+ )
44
+
45
+ def enqueue(self, topic: str, payload: dict, qos: int = 0, retain: bool = False) -> None:
46
+ with self._lock, self._connect() as conn:
47
+ conn.execute(
48
+ "INSERT INTO telemetry_queue (topic, payload, qos, retain) VALUES (?, ?, ?, ?)",
49
+ (topic, json.dumps(payload, separators=(",", ":")), qos, int(retain)),
50
+ )
51
+
52
+ def dequeue_batch(self, limit: int = 100) -> List[BufferedMessage]:
53
+ with self._lock, self._connect() as conn:
54
+ rows = conn.execute(
55
+ "SELECT id, topic, payload, qos, retain FROM telemetry_queue ORDER BY id ASC LIMIT ?",
56
+ (limit,),
57
+ ).fetchall()
58
+ return [
59
+ BufferedMessage(
60
+ id=row[0],
61
+ topic=row[1],
62
+ payload=json.loads(row[2]),
63
+ qos=int(row[3]),
64
+ retain=bool(row[4]),
65
+ )
66
+ for row in rows
67
+ ]
68
+
69
+ def delete(self, ids: Iterable[int]) -> None:
70
+ ids = list(ids)
71
+ if not ids:
72
+ return
73
+ placeholders = ",".join("?" for _ in ids)
74
+ with self._lock, self._connect() as conn:
75
+ conn.execute(f"DELETE FROM telemetry_queue WHERE id IN ({placeholders})", ids)
76
+
77
+ def count(self) -> int:
78
+ with self._lock, self._connect() as conn:
79
+ row = conn.execute("SELECT COUNT(*) FROM telemetry_queue").fetchone()
80
+ return int(row[0]) if row else 0
@@ -0,0 +1,247 @@
1
+ import hashlib
2
+ import json
3
+ import logging
4
+ import os
5
+ import secrets
6
+ import time
7
+ import uuid
8
+ import urllib.error
9
+ import urllib.request
10
+ from dataclasses import dataclass
11
+ from typing import Any, Dict, Optional
12
+
13
+ from .storage import StateStore
14
+ from .version import __version__
15
+
16
+
17
+ CLAIM_TOKEN_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
18
+ logger = logging.getLogger("enigmind_agent.claim")
19
+
20
+
21
+ class ClaimError(RuntimeError):
22
+ pass
23
+
24
+
25
+ class ClaimTimeout(ClaimError):
26
+ pass
27
+
28
+
29
+ class ClaimHttpError(ClaimError):
30
+ def __init__(self, status_code: int, message: str) -> None:
31
+ super().__init__(message)
32
+ self.status_code = status_code
33
+
34
+
35
+ @dataclass
36
+ class ClaimSettings:
37
+ base_url: str
38
+ initiate_path: str = "/api/claims/initiate"
39
+ status_path: str = "/api/claims/{token}/status"
40
+ poll_interval_s: float = 5.0
41
+ timeout_s: float = 300.0
42
+ request_timeout_s: float = 5.0
43
+ max_retries: int = 3
44
+ backoff_s: float = 1.0
45
+ state_path: Optional[str] = None
46
+
47
+
48
+ def generate_claim_token(length: int = 8, alphabet: str = CLAIM_TOKEN_ALPHABET) -> str:
49
+ if length < 6 or length > 8:
50
+ raise ValueError("claim token length must be between 6 and 8 characters")
51
+ return "".join(secrets.choice(alphabet) for _ in range(length))
52
+
53
+
54
+ def hardware_fingerprint() -> str:
55
+ override = os.getenv("ENIGMIND_HARDWARE_ID")
56
+ if override:
57
+ return override.strip()
58
+ machine_id = _read_machine_id()
59
+ if machine_id:
60
+ raw = f"machine:{machine_id}"
61
+ else:
62
+ raw = f"mac:{uuid.getnode()}"
63
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
64
+
65
+
66
+ def _read_machine_id() -> Optional[str]:
67
+ paths = ("/etc/machine-id", "/var/lib/dbus/machine-id")
68
+ for path in paths:
69
+ try:
70
+ with open(path, "r", encoding="utf-8") as handle:
71
+ value = handle.read().strip()
72
+ if value:
73
+ return value
74
+ except OSError:
75
+ continue
76
+ return None
77
+
78
+
79
+ def claim_device(
80
+ settings: ClaimSettings,
81
+ hardware_id: Optional[str] = None,
82
+ token_length: int = 8,
83
+ store: Optional[StateStore] = None,
84
+ ) -> Dict[str, Any]:
85
+ store = store or StateStore(settings.state_path)
86
+ state = store.load()
87
+ if state.credentials:
88
+ return state.credentials
89
+
90
+ had_token = bool(state.claim_token)
91
+ state.hardware_id = state.hardware_id or hardware_id or hardware_fingerprint()
92
+ state.claim_token = state.claim_token or generate_claim_token(token_length)
93
+ store.save(state)
94
+
95
+ logger.info("Device claim token: %s", state.claim_token)
96
+ logger.info("Waiting for claim approval...")
97
+
98
+ client = ClaimClient(settings)
99
+ if had_token:
100
+ try:
101
+ client.initiate(state.hardware_id, state.claim_token)
102
+ except ClaimHttpError as exc:
103
+ if exc.status_code in {400, 409}:
104
+ logger.info("Claim token already initiated; resuming status polling")
105
+ else:
106
+ raise
107
+ else:
108
+ client.initiate(state.hardware_id, state.claim_token)
109
+ payload = client.poll_status(state.claim_token)
110
+ credentials = _extract_credentials(payload)
111
+ if not credentials:
112
+ raise ClaimError("Claim completed without returning credentials")
113
+
114
+ state.credentials = credentials
115
+ store.save(state)
116
+ return credentials
117
+
118
+
119
+ class ClaimClient:
120
+ def __init__(self, settings: ClaimSettings) -> None:
121
+ self.settings = settings
122
+
123
+ def initiate(self, hardware_id: str, claim_token: str) -> Dict[str, Any]:
124
+ payload = {
125
+ "hardware_id": hardware_id,
126
+ "claim_token": claim_token,
127
+ }
128
+ url = _join_url(self.settings.base_url, self.settings.initiate_path)
129
+ return _request_with_retries(
130
+ "POST",
131
+ url,
132
+ payload,
133
+ self.settings,
134
+ )
135
+
136
+ def poll_status(self, claim_token: str) -> Dict[str, Any]:
137
+ url = _join_url(
138
+ self.settings.base_url,
139
+ self.settings.status_path.format(token=claim_token),
140
+ )
141
+ deadline = time.time() + max(self.settings.timeout_s, 0)
142
+ interval = max(self.settings.poll_interval_s, 0.1)
143
+
144
+ while time.time() <= deadline:
145
+ payload = _request_with_retries("GET", url, None, self.settings)
146
+ status = str(payload.get("status", "pending")).lower()
147
+ if status == "claimed":
148
+ logger.info("Claim approved for token %s", claim_token)
149
+ return payload
150
+ if status in {"expired", "rejected", "failed"}:
151
+ raise ClaimError(f"Claim {status} for token {claim_token}")
152
+ time.sleep(interval)
153
+
154
+ raise ClaimTimeout("Timed out waiting for claim acceptance")
155
+
156
+ def get_status(self, claim_token: str) -> Dict[str, Any]:
157
+ url = _join_url(
158
+ self.settings.base_url,
159
+ self.settings.status_path.format(token=claim_token),
160
+ )
161
+ return _request_with_retries("GET", url, None, self.settings)
162
+
163
+
164
+ def _extract_credentials(payload: Dict[str, Any]) -> Dict[str, Any]:
165
+ if isinstance(payload.get("credentials"), dict):
166
+ return payload["credentials"]
167
+ keys = (
168
+ "broker_url",
169
+ "device_id",
170
+ "api_key",
171
+ "username",
172
+ "password",
173
+ "client_id",
174
+ "telemetry_topic",
175
+ "heartbeat_topic",
176
+ "rpc_request_topic",
177
+ "rpc_response_topic",
178
+ "keepalive",
179
+ )
180
+ credentials = {key: payload.get(key) for key in keys if payload.get(key) is not None}
181
+ return credentials
182
+
183
+
184
+ def _request_with_retries(
185
+ method: str,
186
+ url: str,
187
+ payload: Optional[Dict[str, Any]],
188
+ settings: ClaimSettings,
189
+ ) -> Dict[str, Any]:
190
+ attempt = 0
191
+ while True:
192
+ try:
193
+ return _request_json(method, url, payload, settings.request_timeout_s)
194
+ except urllib.error.HTTPError as exc:
195
+ if _is_retryable_http_error(exc) and attempt < settings.max_retries:
196
+ _sleep_backoff(attempt, settings.backoff_s)
197
+ attempt += 1
198
+ continue
199
+ raise ClaimHttpError(
200
+ exc.code, f"Claim request failed with HTTP {exc.code}"
201
+ ) from exc
202
+ except urllib.error.URLError as exc:
203
+ if attempt < settings.max_retries:
204
+ _sleep_backoff(attempt, settings.backoff_s)
205
+ attempt += 1
206
+ continue
207
+ raise ClaimError("Claim request failed due to network error") from exc
208
+
209
+
210
+ def _request_json(
211
+ method: str,
212
+ url: str,
213
+ payload: Optional[Dict[str, Any]],
214
+ timeout_s: float,
215
+ ) -> Dict[str, Any]:
216
+ data = None
217
+ headers = {
218
+ "Accept": "application/json",
219
+ "User-Agent": f"enigmind-agent/{__version__}",
220
+ }
221
+ if payload is not None:
222
+ data = json.dumps(payload, separators=(",", ":")).encode("utf-8")
223
+ headers["Content-Type"] = "application/json"
224
+
225
+ request = urllib.request.Request(url, data=data, method=method.upper())
226
+ for key, value in headers.items():
227
+ request.add_header(key, value)
228
+
229
+ with urllib.request.urlopen(request, timeout=timeout_s) as response:
230
+ body = response.read()
231
+ if not body:
232
+ return {}
233
+ return json.loads(body.decode("utf-8"))
234
+
235
+
236
+ def _is_retryable_http_error(error: urllib.error.HTTPError) -> bool:
237
+ return error.code in {408, 425, 429} or 500 <= error.code <= 599
238
+
239
+
240
+ def _sleep_backoff(attempt: int, backoff_s: float) -> None:
241
+ delay = backoff_s * (2**attempt)
242
+ if delay > 0:
243
+ time.sleep(delay)
244
+
245
+
246
+ def _join_url(base_url: str, path: str) -> str:
247
+ return f"{base_url.rstrip('/')}/{path.lstrip('/')}"
enigmind_agent/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ import json
2
+ import logging
3
+ import time
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich import print as rprint
8
+
9
+ from .agent import Agent
10
+ from .claim import ClaimClient, ClaimError, ClaimSettings
11
+ from .config import AgentConfig
12
+ from .plugins.health import HealthPlugin
13
+ from .storage import StateStore
14
+
15
+ app = typer.Typer(help="Enigmind Agent CLI")
16
+ claim_app = typer.Typer(help="Device claim helpers")
17
+
18
+
19
+ @app.callback()
20
+ def main(verbose: bool = typer.Option(False, "--verbose", help="Enable debug logging")) -> None:
21
+ level = logging.DEBUG if verbose else logging.INFO
22
+ logging.basicConfig(level=level, format="%(message)s")
23
+
24
+
25
+ def _load_config(path: Optional[str]) -> AgentConfig:
26
+ if path:
27
+ return AgentConfig.from_file(path)
28
+ return AgentConfig.from_env()
29
+
30
+
31
+ @app.command("send")
32
+ def send_command(
33
+ data: str = typer.Option(..., "--data", help="Telemetry JSON payload"),
34
+ config: Optional[str] = typer.Option(None, "--config", help="Path to config JSON"),
35
+ ) -> None:
36
+ payload = json.loads(data)
37
+ agent = Agent(_load_config(config))
38
+ agent.start()
39
+ agent.send_telemetry(payload, force=True)
40
+ time.sleep(0.5)
41
+ agent.stop()
42
+ rprint("[green]Sent telemetry[/green]")
43
+
44
+
45
+ @app.command("status")
46
+ def status_command(
47
+ config: Optional[str] = typer.Option(None, "--config", help="Path to config JSON"),
48
+ health: bool = typer.Option(False, "--health", help="Include health metrics"),
49
+ ) -> None:
50
+ cfg = _load_config(config)
51
+ agent = Agent(cfg)
52
+ rprint({
53
+ "broker": cfg.broker_url,
54
+ "client_id": cfg.client_id,
55
+ "telemetry_topic": cfg.telemetry_topic,
56
+ "buffered": agent.buffer_count(),
57
+ })
58
+ if health:
59
+ plugin = HealthPlugin()
60
+ rprint(plugin.collect_telemetry())
61
+
62
+
63
+ @app.command("run")
64
+ def run_command(
65
+ config: Optional[str] = typer.Option(None, "--config", help="Path to config JSON"),
66
+ ) -> None:
67
+ cfg = _load_config(config)
68
+ agent = Agent(cfg)
69
+ try:
70
+ agent.start()
71
+ while True:
72
+ time.sleep(1)
73
+ except KeyboardInterrupt:
74
+ pass
75
+ finally:
76
+ agent.stop()
77
+
78
+
79
+ @claim_app.command("status")
80
+ def claim_status_command(
81
+ config: Optional[str] = typer.Option(None, "--config", help="Path to config JSON"),
82
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="Override claim base URL"),
83
+ ) -> None:
84
+ cfg = _load_config(config)
85
+ claim_cfg = cfg.claim
86
+ base_url = base_url or claim_cfg.base_url
87
+ store = StateStore(claim_cfg.state_path)
88
+ state = store.load()
89
+ if not state.claim_token:
90
+ rprint("[yellow]No claim token found locally.[/yellow]")
91
+ return
92
+
93
+ output = {
94
+ "token": state.claim_token,
95
+ "hardware_id": state.hardware_id,
96
+ "credentials": "stored" if state.credentials else "missing",
97
+ }
98
+
99
+ if not base_url:
100
+ output["status"] = "unknown (missing claim base URL)"
101
+ rprint(output)
102
+ return
103
+
104
+ settings = ClaimSettings(
105
+ base_url=base_url,
106
+ initiate_path=claim_cfg.initiate_path,
107
+ status_path=claim_cfg.status_path,
108
+ poll_interval_s=claim_cfg.poll_interval_s,
109
+ timeout_s=claim_cfg.timeout_s,
110
+ request_timeout_s=claim_cfg.request_timeout_s,
111
+ max_retries=claim_cfg.max_retries,
112
+ backoff_s=claim_cfg.backoff_s,
113
+ state_path=claim_cfg.state_path,
114
+ )
115
+ client = ClaimClient(settings)
116
+ try:
117
+ status_payload = client.get_status(state.claim_token)
118
+ output["status"] = status_payload.get("status", "unknown")
119
+ except ClaimError as exc:
120
+ output["status_error"] = str(exc)
121
+ rprint(output)
122
+
123
+
124
+ app.add_typer(claim_app, name="claim")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ app()
@@ -0,0 +1,158 @@
1
+ import json
2
+ import os
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Optional
5
+ from urllib.parse import urlparse
6
+
7
+ from platformdirs import user_data_dir
8
+
9
+
10
+ @dataclass
11
+ class TLSConfig:
12
+ ca_cert: Optional[str] = None
13
+ client_cert: Optional[str] = None
14
+ client_key: Optional[str] = None
15
+ insecure: bool = False
16
+
17
+
18
+ @dataclass
19
+ class ClaimConfig:
20
+ base_url: Optional[str] = None
21
+ initiate_path: str = "/api/claims/initiate"
22
+ status_path: str = "/api/claims/{token}/status"
23
+ poll_interval_s: int = 5
24
+ timeout_s: int = 300
25
+ request_timeout_s: int = 5
26
+ max_retries: int = 3
27
+ backoff_s: float = 1.0
28
+ state_path: Optional[str] = None
29
+
30
+
31
+ @dataclass
32
+ class AgentConfig:
33
+ broker_url: str
34
+ device_id: Optional[str] = None
35
+ api_key: Optional[str] = None
36
+ username: Optional[str] = None
37
+ password: Optional[str] = None
38
+ client_id: Optional[str] = None
39
+ keepalive: int = 60
40
+ telemetry_topic: str = "v1/devices/me/telemetry"
41
+ heartbeat_topic: str = "v1/devices/me/telemetry"
42
+ rpc_request_topic: str = "v1/devices/me/rpc/request/+"
43
+ rpc_response_topic: str = "v1/devices/me/rpc/response/{request_id}"
44
+ heartbeat_interval_s: int = 60
45
+ buffer_path: Optional[str] = None
46
+ delta_sync: bool = True
47
+ tls: TLSConfig = field(default_factory=TLSConfig)
48
+ claim: ClaimConfig = field(default_factory=ClaimConfig)
49
+
50
+ def normalized(self) -> "AgentConfig":
51
+ if not self.client_id:
52
+ self.client_id = self.device_id or "enigmind-agent"
53
+ if not self.username and self.device_id:
54
+ self.username = self.device_id
55
+ if not self.password and self.api_key:
56
+ self.password = self.api_key
57
+ if not self.buffer_path:
58
+ data_dir = user_data_dir("enigmind_agent")
59
+ self.buffer_path = os.path.join(data_dir, "buffer.db")
60
+ return self
61
+
62
+ def broker_host_port(self) -> Dict[str, Any]:
63
+ parsed = urlparse(self.broker_url)
64
+ host = parsed.hostname or self.broker_url
65
+ port = parsed.port
66
+ use_tls = parsed.scheme in ("mqtts", "ssl", "tls")
67
+ if not port:
68
+ port = 8883 if use_tls else 1883
69
+ return {"host": host, "port": port, "use_tls": use_tls}
70
+
71
+ def has_credentials(self) -> bool:
72
+ return bool(self.device_id and (self.api_key or self.password))
73
+
74
+ @staticmethod
75
+ def from_env(prefix: str = "ENIGMIND_") -> "AgentConfig":
76
+ def env(key: str, default: Optional[str] = None) -> Optional[str]:
77
+ return os.getenv(prefix + key, default)
78
+
79
+ cfg = AgentConfig(
80
+ broker_url=env("BROKER_URL", "mqtt://localhost:1883"),
81
+ device_id=env("DEVICE_ID"),
82
+ api_key=env("API_KEY"),
83
+ username=env("USERNAME"),
84
+ password=env("PASSWORD"),
85
+ client_id=env("CLIENT_ID"),
86
+ telemetry_topic=env("TELEMETRY_TOPIC", "v1/devices/me/telemetry"),
87
+ heartbeat_topic=env("HEARTBEAT_TOPIC", "v1/devices/me/telemetry"),
88
+ rpc_request_topic=env("RPC_REQUEST_TOPIC", "v1/devices/me/rpc/request/+"),
89
+ rpc_response_topic=env("RPC_RESPONSE_TOPIC", "v1/devices/me/rpc/response/{request_id}"),
90
+ )
91
+ cfg.heartbeat_interval_s = int(env("HEARTBEAT_INTERVAL_S", "60"))
92
+ cfg.keepalive = int(env("KEEPALIVE", "60"))
93
+ cfg.delta_sync = env("DELTA_SYNC", "true").lower() == "true"
94
+ cfg.buffer_path = env("BUFFER_PATH")
95
+ cfg.tls = TLSConfig(
96
+ ca_cert=env("TLS_CA_CERT"),
97
+ client_cert=env("TLS_CLIENT_CERT"),
98
+ client_key=env("TLS_CLIENT_KEY"),
99
+ insecure=env("TLS_INSECURE", "false").lower() == "true",
100
+ )
101
+ cfg.claim = ClaimConfig(
102
+ base_url=env("CLAIM_BASE_URL"),
103
+ initiate_path=env("CLAIM_INITIATE_PATH", "/api/claims/initiate"),
104
+ status_path=env("CLAIM_STATUS_PATH", "/api/claims/{token}/status"),
105
+ poll_interval_s=int(env("CLAIM_POLL_INTERVAL_S", "5")),
106
+ timeout_s=int(env("CLAIM_TIMEOUT_S", "300")),
107
+ request_timeout_s=int(env("CLAIM_REQUEST_TIMEOUT_S", "5")),
108
+ max_retries=int(env("CLAIM_MAX_RETRIES", "3")),
109
+ backoff_s=float(env("CLAIM_BACKOFF_S", "1.0")),
110
+ state_path=env("CLAIM_STATE_PATH"),
111
+ )
112
+ return cfg.normalized()
113
+
114
+ @staticmethod
115
+ def from_file(path: str) -> "AgentConfig":
116
+ with open(path, "r", encoding="utf-8") as handle:
117
+ data = json.load(handle)
118
+ delta_sync = data.get("delta_sync", True)
119
+ if isinstance(delta_sync, str):
120
+ delta_sync = delta_sync.lower() == "true"
121
+ tls_insecure = data.get("tls", {}).get("insecure", False)
122
+ if isinstance(tls_insecure, str):
123
+ tls_insecure = tls_insecure.lower() == "true"
124
+ claim = data.get("claim", {})
125
+ cfg = AgentConfig(
126
+ broker_url=data["broker_url"],
127
+ device_id=data.get("device_id"),
128
+ api_key=data.get("api_key"),
129
+ username=data.get("username"),
130
+ password=data.get("password"),
131
+ client_id=data.get("client_id"),
132
+ keepalive=int(data.get("keepalive", 60)),
133
+ telemetry_topic=data.get("telemetry_topic", "v1/devices/me/telemetry"),
134
+ heartbeat_topic=data.get("heartbeat_topic", "v1/devices/me/telemetry"),
135
+ rpc_request_topic=data.get("rpc_request_topic", "v1/devices/me/rpc/request/+"),
136
+ rpc_response_topic=data.get("rpc_response_topic", "v1/devices/me/rpc/response/{request_id}"),
137
+ heartbeat_interval_s=int(data.get("heartbeat_interval_s", 60)),
138
+ buffer_path=data.get("buffer_path"),
139
+ delta_sync=bool(delta_sync),
140
+ tls=TLSConfig(
141
+ ca_cert=data.get("tls", {}).get("ca_cert"),
142
+ client_cert=data.get("tls", {}).get("client_cert"),
143
+ client_key=data.get("tls", {}).get("client_key"),
144
+ insecure=bool(tls_insecure),
145
+ ),
146
+ claim=ClaimConfig(
147
+ base_url=claim.get("base_url"),
148
+ initiate_path=claim.get("initiate_path", "/api/claims/initiate"),
149
+ status_path=claim.get("status_path", "/api/claims/{token}/status"),
150
+ poll_interval_s=int(claim.get("poll_interval_s", 5)),
151
+ timeout_s=int(claim.get("timeout_s", 300)),
152
+ request_timeout_s=int(claim.get("request_timeout_s", 5)),
153
+ max_retries=int(claim.get("max_retries", 3)),
154
+ backoff_s=float(claim.get("backoff_s", 1.0)),
155
+ state_path=claim.get("state_path"),
156
+ ),
157
+ )
158
+ return cfg.normalized()
@@ -0,0 +1,18 @@
1
+ from typing import Dict, Tuple
2
+
3
+
4
+ class DeltaFilter:
5
+ def __init__(self) -> None:
6
+ self._last: Dict[str, object] = {}
7
+
8
+ def diff(self, payload: Dict[str, object]) -> Tuple[Dict[str, object], bool]:
9
+ changed: Dict[str, object] = {}
10
+ for key, value in payload.items():
11
+ if key not in self._last or self._last[key] != value:
12
+ changed[key] = value
13
+ if changed:
14
+ self._last.update(changed)
15
+ return changed, bool(changed)
16
+
17
+ def reset(self) -> None:
18
+ self._last.clear()
enigmind_agent/mqtt.py ADDED
@@ -0,0 +1,92 @@
1
+ import json
2
+ import ssl
3
+ import threading
4
+ from typing import Any, Callable, Optional
5
+
6
+ import paho.mqtt.client as mqtt
7
+
8
+ from .config import AgentConfig
9
+
10
+
11
+ MessageHandler = Callable[[str, bytes], None]
12
+ ConnectHandler = Callable[[], None]
13
+
14
+
15
+ class MQTTClient:
16
+ def __init__(
17
+ self,
18
+ config: AgentConfig,
19
+ on_message: Optional[MessageHandler] = None,
20
+ on_connect: Optional[ConnectHandler] = None,
21
+ ) -> None:
22
+ self.config = config
23
+ self.on_message = on_message
24
+ self.on_connect = on_connect
25
+ self._client = mqtt.Client(
26
+ client_id=self.config.client_id,
27
+ protocol=mqtt.MQTTv5,
28
+ )
29
+ self._connected = threading.Event()
30
+ self._setup_client()
31
+
32
+ @property
33
+ def connected(self) -> bool:
34
+ return self._connected.is_set()
35
+
36
+ def _setup_client(self) -> None:
37
+ if self.config.username or self.config.password:
38
+ self._client.username_pw_set(self.config.username, self.config.password)
39
+
40
+ self._client.on_connect = self._handle_connect
41
+ self._client.on_disconnect = self._handle_disconnect
42
+ self._client.on_message = self._handle_message
43
+
44
+ if self.config.tls:
45
+ self._configure_tls()
46
+
47
+ def _configure_tls(self) -> None:
48
+ use_tls = self.config.broker_host_port().get("use_tls", False)
49
+ if not use_tls and not (self.config.tls.ca_cert or self.config.tls.client_cert):
50
+ return
51
+ self._client.tls_set(
52
+ ca_certs=self.config.tls.ca_cert,
53
+ certfile=self.config.tls.client_cert,
54
+ keyfile=self.config.tls.client_key,
55
+ cert_reqs=ssl.CERT_REQUIRED if not self.config.tls.insecure else ssl.CERT_NONE,
56
+ tls_version=ssl.PROTOCOL_TLS_CLIENT,
57
+ )
58
+ if self.config.tls.insecure:
59
+ self._client.tls_insecure_set(True)
60
+
61
+ def connect(self) -> None:
62
+ broker = self.config.broker_host_port()
63
+ self._client.connect(broker["host"], broker["port"], keepalive=self.config.keepalive)
64
+ self._client.loop_start()
65
+
66
+ def disconnect(self) -> None:
67
+ self._client.loop_stop()
68
+ self._client.disconnect()
69
+ self._connected.clear()
70
+
71
+ def subscribe(self, topic: str, qos: int = 0) -> None:
72
+ self._client.subscribe(topic, qos=qos)
73
+
74
+ def publish(self, topic: str, payload: dict, qos: int = 0, retain: bool = False) -> bool:
75
+ data = json.dumps(payload, separators=(",", ":"))
76
+ result = self._client.publish(topic, data, qos=qos, retain=retain)
77
+ return result.rc == mqtt.MQTT_ERR_SUCCESS
78
+
79
+ def _handle_connect(self, client: mqtt.Client, userdata: Any, flags: dict, reason_code: int, properties: Any = None) -> None:
80
+ if reason_code == 0:
81
+ self._connected.set()
82
+ if self.on_connect:
83
+ self.on_connect()
84
+ else:
85
+ self._connected.clear()
86
+
87
+ def _handle_disconnect(self, client: mqtt.Client, userdata: Any, reason_code: int, properties: Any = None) -> None:
88
+ self._connected.clear()
89
+
90
+ def _handle_message(self, client: mqtt.Client, userdata: Any, msg: mqtt.MQTTMessage) -> None:
91
+ if self.on_message:
92
+ self.on_message(msg.topic, msg.payload)
@@ -0,0 +1,5 @@
1
+ from .base import Plugin
2
+ from .geo import GeoPlugin
3
+ from .health import HealthPlugin
4
+
5
+ __all__ = ["Plugin", "GeoPlugin", "HealthPlugin"]
@@ -0,0 +1,14 @@
1
+ from typing import Dict
2
+
3
+
4
+ class Plugin:
5
+ name = "base"
6
+
7
+ def on_start(self, agent: "Agent") -> None:
8
+ return None
9
+
10
+ def on_stop(self, agent: "Agent") -> None:
11
+ return None
12
+
13
+ def collect_telemetry(self) -> Dict[str, object]:
14
+ return {}
@@ -0,0 +1,78 @@
1
+ import math
2
+ from dataclasses import dataclass
3
+ from typing import Callable, Dict, List, Optional, Tuple
4
+
5
+ from .base import Plugin
6
+
7
+
8
+ GeoEventHandler = Callable[[str, str, Dict[str, float]], None]
9
+
10
+
11
+ @dataclass
12
+ class CircleFence:
13
+ center_lat: float
14
+ center_lon: float
15
+ radius_m: float
16
+
17
+
18
+ @dataclass
19
+ class PolygonFence:
20
+ points: List[Tuple[float, float]]
21
+
22
+
23
+ class GeoPlugin(Plugin):
24
+ name = "geo"
25
+
26
+ def __init__(self, on_event: Optional[GeoEventHandler] = None) -> None:
27
+ self._circles: Dict[str, CircleFence] = {}
28
+ self._polygons: Dict[str, PolygonFence] = {}
29
+ self._state: Dict[str, bool] = {}
30
+ self._on_event = on_event
31
+
32
+ def add_circle(self, geofence_id: str, center_lat: float, center_lon: float, radius_m: float) -> None:
33
+ self._circles[geofence_id] = CircleFence(center_lat, center_lon, radius_m)
34
+
35
+ def add_polygon(self, geofence_id: str, points: List[Tuple[float, float]]) -> None:
36
+ self._polygons[geofence_id] = PolygonFence(points)
37
+
38
+ def process_position(self, lat: float, lon: float) -> None:
39
+ for geofence_id, fence in self._circles.items():
40
+ inside = _haversine_m(lat, lon, fence.center_lat, fence.center_lon) <= fence.radius_m
41
+ self._emit_if_changed(geofence_id, inside, lat, lon)
42
+ for geofence_id, fence in self._polygons.items():
43
+ inside = _point_in_polygon(lat, lon, fence.points)
44
+ self._emit_if_changed(geofence_id, inside, lat, lon)
45
+
46
+ def _emit_if_changed(self, geofence_id: str, inside: bool, lat: float, lon: float) -> None:
47
+ prev = self._state.get(geofence_id)
48
+ if prev is None or prev != inside:
49
+ self._state[geofence_id] = inside
50
+ if self._on_event:
51
+ event = "enter" if inside else "exit"
52
+ self._on_event(geofence_id, event, {"lat": lat, "lon": lon})
53
+
54
+
55
+ def _haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
56
+ r = 6371000.0
57
+ phi1 = math.radians(lat1)
58
+ phi2 = math.radians(lat2)
59
+ dphi = math.radians(lat2 - lat1)
60
+ dlambda = math.radians(lon2 - lon1)
61
+
62
+ a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
63
+ return 2 * r * math.atan2(math.sqrt(a), math.sqrt(1 - a))
64
+
65
+
66
+ def _point_in_polygon(lat: float, lon: float, points: List[Tuple[float, float]]) -> bool:
67
+ inside = False
68
+ j = len(points) - 1
69
+ for i in range(len(points)):
70
+ yi, xi = points[i]
71
+ yj, xj = points[j]
72
+ intersects = ((xi > lon) != (xj > lon)) and (
73
+ lat < (yj - yi) * (lon - xi) / (xj - xi + 1e-12) + yi
74
+ )
75
+ if intersects:
76
+ inside = not inside
77
+ j = i
78
+ return inside
@@ -0,0 +1,24 @@
1
+ from typing import Dict
2
+
3
+ from .base import Plugin
4
+
5
+
6
+ class HealthPlugin(Plugin):
7
+ name = "health"
8
+
9
+ def __init__(self) -> None:
10
+ try:
11
+ import psutil # noqa: F401
12
+ except Exception as exc: # pragma: no cover
13
+ raise RuntimeError("psutil is required for HealthPlugin. Install with enigmind-agent[health].") from exc
14
+
15
+ def collect_telemetry(self) -> Dict[str, object]:
16
+ import psutil
17
+
18
+ mem = psutil.virtual_memory()
19
+ disk = psutil.disk_usage("/")
20
+ return {
21
+ "cpu_percent": psutil.cpu_percent(interval=None),
22
+ "mem_percent": mem.percent,
23
+ "disk_percent": disk.percent,
24
+ }
enigmind_agent/rpc.py ADDED
@@ -0,0 +1,34 @@
1
+ import json
2
+ from typing import Any, Callable, Dict, Optional
3
+
4
+
5
+ RpcHandler = Callable[[Any], Any]
6
+
7
+
8
+ class RpcRouter:
9
+ def __init__(self) -> None:
10
+ self._handlers: Dict[str, RpcHandler] = {}
11
+
12
+ def register(self, method: str, handler: RpcHandler) -> None:
13
+ self._handlers[method] = handler
14
+
15
+ def handle(self, payload: bytes) -> Optional[Dict[str, Any]]:
16
+ try:
17
+ message = json.loads(payload.decode("utf-8"))
18
+ except json.JSONDecodeError:
19
+ return {"error": "invalid_json"}
20
+
21
+ method = message.get("method")
22
+ if not method:
23
+ return {"error": "missing_method"}
24
+
25
+ handler = self._handlers.get(method)
26
+ if not handler:
27
+ return {"error": "unknown_method", "method": method}
28
+
29
+ params = message.get("params")
30
+ try:
31
+ result = handler(params)
32
+ return {"result": result}
33
+ except Exception as exc:
34
+ return {"error": "handler_error", "message": str(exc)}
@@ -0,0 +1,62 @@
1
+ import json
2
+ import os
3
+ import threading
4
+ from dataclasses import dataclass
5
+ from typing import Any, Dict, Optional
6
+
7
+ from platformdirs import user_data_dir
8
+
9
+
10
+ def default_state_path() -> str:
11
+ data_dir = user_data_dir("enigmind_agent")
12
+ return os.path.join(data_dir, "state.json")
13
+
14
+
15
+ @dataclass
16
+ class ClaimState:
17
+ hardware_id: Optional[str] = None
18
+ claim_token: Optional[str] = None
19
+ credentials: Optional[Dict[str, Any]] = None
20
+
21
+ @classmethod
22
+ def from_dict(cls, data: Dict[str, Any]) -> "ClaimState":
23
+ return cls(
24
+ hardware_id=data.get("hardware_id"),
25
+ claim_token=data.get("claim_token"),
26
+ credentials=data.get("credentials"),
27
+ )
28
+
29
+ def to_dict(self) -> Dict[str, Any]:
30
+ return {
31
+ "hardware_id": self.hardware_id,
32
+ "claim_token": self.claim_token,
33
+ "credentials": self.credentials,
34
+ }
35
+
36
+
37
+ class StateStore:
38
+ def __init__(self, path: Optional[str] = None) -> None:
39
+ self.path = path or default_state_path()
40
+ self._lock = threading.Lock()
41
+
42
+ def load(self) -> ClaimState:
43
+ if not os.path.exists(self.path):
44
+ return ClaimState()
45
+ with self._lock, open(self.path, "r", encoding="utf-8") as handle:
46
+ try:
47
+ data = json.load(handle)
48
+ except json.JSONDecodeError:
49
+ return ClaimState()
50
+ if not isinstance(data, dict):
51
+ return ClaimState()
52
+ return ClaimState.from_dict(data)
53
+
54
+ def save(self, state: ClaimState) -> None:
55
+ directory = os.path.dirname(self.path)
56
+ if directory:
57
+ os.makedirs(directory, exist_ok=True)
58
+ payload = state.to_dict()
59
+ temp_path = f"{self.path}.tmp"
60
+ with self._lock, open(temp_path, "w", encoding="utf-8") as handle:
61
+ json.dump(payload, handle, indent=2, sort_keys=True)
62
+ os.replace(temp_path, self.path)
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ Metadata-Version: 2.4
2
+ Name: enigmind-agent
3
+ Version: 0.1.0
4
+ Summary: Enigmind Agent Python SDK for secure IoT connectivity.
5
+ Author: Enigmind
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://enigmind.ai
8
+ Keywords: iot,mqtt,telemetry,enigmind
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: License :: Other/Proprietary License
17
+ Classifier: Operating System :: OS Independent
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: paho-mqtt<3,>=2.1.0
21
+ Requires-Dist: platformdirs>=4.5.1
22
+ Requires-Dist: typer[standard]>=0.21.1
23
+ Provides-Extra: health
24
+ Requires-Dist: psutil>=7.2.2; extra == "health"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=9.0.2; extra == "dev"
27
+
28
+ # Enigmind Agent Python SDK
29
+
30
+ A lightweight Python SDK for secure IoT device connectivity.
31
+
32
+ ## Quick Start
33
+
34
+ ```python
35
+ from enigmind_agent import Agent, AgentConfig
36
+
37
+ config = AgentConfig(
38
+ broker_url="mqtts://broker.enigmind.ai:8883",
39
+ device_id="device-123",
40
+ api_key="YOUR_API_KEY",
41
+ )
42
+
43
+ agent = Agent(config)
44
+ agent.start()
45
+ agent.send_telemetry({"temp": 22.4, "vibration": 0.12})
46
+ ```
47
+
48
+ ## Device Claim Flow (Zero-Touch Setup)
49
+
50
+ If the agent starts without credentials (`device_id` + `api_key`/`password`), it automatically enters the claim flow:
51
+
52
+ 1. Generates a stable hardware fingerprint and a human-readable claim token.
53
+ 2. Calls the claim API (`/api/claims/initiate`).
54
+ 3. Polls `/api/claims/{token}/status` until approved.
55
+ 4. Stores the returned credentials locally and connects to MQTT.
56
+
57
+ The claim token is printed at startup when using the CLI, and is also stored locally for retrieval.
58
+ By default the state file is saved under your user data directory as `enigmind_agent/state.json`.
59
+ If you embed the SDK directly, enable Python logging at INFO level to see claim output.
60
+
61
+ ### CLI Example
62
+
63
+ ```bash
64
+ ENIGMIND_CLAIM_BASE_URL=https://api.enigmind.ai enigmind-cli run
65
+ ```
66
+
67
+ Check claim status and token:
68
+
69
+ ```bash
70
+ enigmind-cli claim status
71
+ ```
72
+
73
+ ### JSON Config Example
74
+
75
+ ```json
76
+ {
77
+ "broker_url": "mqtts://broker.enigmind.ai:8883",
78
+ "claim": {
79
+ "base_url": "https://api.enigmind.ai",
80
+ "poll_interval_s": 5,
81
+ "timeout_s": 300
82
+ }
83
+ }
84
+ ```
85
+
86
+ ## Installation & Setup
87
+
88
+ ```powershell
89
+ py -3.12 -m venv .venv
90
+ .venv\Scripts\Activate.ps1
91
+ python -m pip install -U pip
92
+ pip install -e .[dev]
93
+ ```
94
+
95
+ Optional Health plugin:
96
+
97
+ ```powershell
98
+ pip install -e .[health]
99
+ ```
100
+
101
+ ## CLI
102
+
103
+ ```bash
104
+ enigmind-cli send --data '{"temp": 22.4}' --config ./enigmind.json
105
+ ```
106
+
107
+ ## Releasing
108
+
109
+ Releases are published to PyPI automatically via GitHub Actions when you create a GitHub Release.
110
+
111
+ 1. Bump `version` in `pyproject.toml`.
112
+ 2. Commit and push to `main`.
113
+ 3. Go to **GitHub → Releases → Draft a new release**.
114
+ 4. Create a new tag (e.g. `v0.1.0`), add release notes, and click **Publish release**.
115
+ 5. The `release.yml` workflow will build and publish the package to PyPI.
116
+
117
+ ## Plugins
118
+
119
+ - Health plugin: CPU/memory/disk metrics (requires optional dependency `psutil`).
120
+ - Geo plugin: local geofencing hooks (no extra dependencies).
@@ -0,0 +1,20 @@
1
+ enigmind_agent/__init__.py,sha256=0BIr0ON2rTWjEKov5BgNgvgKN6v1lJRGCsvQXI6AnOU,404
2
+ enigmind_agent/agent.py,sha256=iDxgYD7L_c9qzs8mMgbjs7Vr4qKddyAPXtrxa3uLtuo,6213
3
+ enigmind_agent/buffer.py,sha256=Is70Sh_RvaNScF46xGn7jDI7tyQptz65jhxmA4HR8d4,2584
4
+ enigmind_agent/claim.py,sha256=HFIxkE94sljKENVWQlYnciyQWNoHTwiWMvICN5dRg1M,7603
5
+ enigmind_agent/cli.py,sha256=CkaDn5ssIRc99X_3NT0vmzgdYPKf6ms_ikrb7Z-fSYI,3698
6
+ enigmind_agent/config.py,sha256=oMHPiK8P5bXWZCh0FMNrFXVRV_ogMSQTXlBjmiQN_mc,6674
7
+ enigmind_agent/delta.py,sha256=LOU-RIkf2re1jtkFFK5phMaXWnk_9rjd5kzu_H1EGAY,550
8
+ enigmind_agent/mqtt.py,sha256=WDqKVVQ7IsnckWMpP4Vp6NU6XeoUf14DqTG3LGVGH1g,3236
9
+ enigmind_agent/rpc.py,sha256=CuBqMSV8BEgz7hPdBzo3kvAtnoj-EuGWVQrEAaLT_U0,1000
10
+ enigmind_agent/storage.py,sha256=uCNd4yR-ohRPJZZ46UB00wQef49NxkDaz2-qIyjRvaw,1919
11
+ enigmind_agent/version.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
12
+ enigmind_agent/plugins/__init__.py,sha256=zXAv_aXezPQ3wV-nuK4KSqmO4pzN3xlsD1yN5m4XKag,136
13
+ enigmind_agent/plugins/base.py,sha256=VmvGC5wTpqW9NTxJQ507xRni6gaDiFuAvd52dIyFxY0,268
14
+ enigmind_agent/plugins/geo.py,sha256=ppXxZYBJExaANOHK0ZaMvondZ7KM8U_pC3TJ7VdlXc0,2697
15
+ enigmind_agent/plugins/health.py,sha256=1ha3uCJVbHszpw64ypP9k6U5eax1L2eI2zn9WX2zrBM,681
16
+ enigmind_agent-0.1.0.dist-info/METADATA,sha256=KDkv2SoMQnL58Da43sWqoRdMYZScjF_fh2tyYqdoM6g,3304
17
+ enigmind_agent-0.1.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
18
+ enigmind_agent-0.1.0.dist-info/entry_points.txt,sha256=HHLuxFnnWhnsdPagMMmGzrKK4ufOm-EfzwspTYTkox0,56
19
+ enigmind_agent-0.1.0.dist-info/top_level.txt,sha256=SKGLe5AlvutKnHqVBsil43qx9YLn73I1Hw-RDBJy_Kw,15
20
+ enigmind_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ enigmind-cli = enigmind_agent.cli:app
@@ -0,0 +1 @@
1
+ enigmind_agent