enigmind-agent 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. enigmind_agent-0.1.0/PKG-INFO +120 -0
  2. enigmind_agent-0.1.0/README.md +93 -0
  3. enigmind_agent-0.1.0/enigmind_agent/__init__.py +16 -0
  4. enigmind_agent-0.1.0/enigmind_agent/agent.py +162 -0
  5. enigmind_agent-0.1.0/enigmind_agent/buffer.py +80 -0
  6. enigmind_agent-0.1.0/enigmind_agent/claim.py +247 -0
  7. enigmind_agent-0.1.0/enigmind_agent/cli.py +128 -0
  8. enigmind_agent-0.1.0/enigmind_agent/config.py +158 -0
  9. enigmind_agent-0.1.0/enigmind_agent/delta.py +18 -0
  10. enigmind_agent-0.1.0/enigmind_agent/mqtt.py +92 -0
  11. enigmind_agent-0.1.0/enigmind_agent/plugins/__init__.py +5 -0
  12. enigmind_agent-0.1.0/enigmind_agent/plugins/base.py +14 -0
  13. enigmind_agent-0.1.0/enigmind_agent/plugins/geo.py +78 -0
  14. enigmind_agent-0.1.0/enigmind_agent/plugins/health.py +24 -0
  15. enigmind_agent-0.1.0/enigmind_agent/rpc.py +34 -0
  16. enigmind_agent-0.1.0/enigmind_agent/storage.py +62 -0
  17. enigmind_agent-0.1.0/enigmind_agent/version.py +1 -0
  18. enigmind_agent-0.1.0/enigmind_agent.egg-info/PKG-INFO +120 -0
  19. enigmind_agent-0.1.0/enigmind_agent.egg-info/SOURCES.txt +28 -0
  20. enigmind_agent-0.1.0/enigmind_agent.egg-info/dependency_links.txt +1 -0
  21. enigmind_agent-0.1.0/enigmind_agent.egg-info/entry_points.txt +2 -0
  22. enigmind_agent-0.1.0/enigmind_agent.egg-info/requires.txt +9 -0
  23. enigmind_agent-0.1.0/enigmind_agent.egg-info/top_level.txt +1 -0
  24. enigmind_agent-0.1.0/pyproject.toml +53 -0
  25. enigmind_agent-0.1.0/setup.cfg +4 -0
  26. enigmind_agent-0.1.0/tests/test_buffer.py +18 -0
  27. enigmind_agent-0.1.0/tests/test_claim.py +110 -0
  28. enigmind_agent-0.1.0/tests/test_delta.py +16 -0
  29. enigmind_agent-0.1.0/tests/test_mqtt.py +59 -0
  30. enigmind_agent-0.1.0/tests/test_rpc.py +21 -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,93 @@
1
+ # Enigmind Agent Python SDK
2
+
3
+ A lightweight Python SDK for secure IoT device connectivity.
4
+
5
+ ## Quick Start
6
+
7
+ ```python
8
+ from enigmind_agent import Agent, AgentConfig
9
+
10
+ config = AgentConfig(
11
+ broker_url="mqtts://broker.enigmind.ai:8883",
12
+ device_id="device-123",
13
+ api_key="YOUR_API_KEY",
14
+ )
15
+
16
+ agent = Agent(config)
17
+ agent.start()
18
+ agent.send_telemetry({"temp": 22.4, "vibration": 0.12})
19
+ ```
20
+
21
+ ## Device Claim Flow (Zero-Touch Setup)
22
+
23
+ If the agent starts without credentials (`device_id` + `api_key`/`password`), it automatically enters the claim flow:
24
+
25
+ 1. Generates a stable hardware fingerprint and a human-readable claim token.
26
+ 2. Calls the claim API (`/api/claims/initiate`).
27
+ 3. Polls `/api/claims/{token}/status` until approved.
28
+ 4. Stores the returned credentials locally and connects to MQTT.
29
+
30
+ The claim token is printed at startup when using the CLI, and is also stored locally for retrieval.
31
+ By default the state file is saved under your user data directory as `enigmind_agent/state.json`.
32
+ If you embed the SDK directly, enable Python logging at INFO level to see claim output.
33
+
34
+ ### CLI Example
35
+
36
+ ```bash
37
+ ENIGMIND_CLAIM_BASE_URL=https://api.enigmind.ai enigmind-cli run
38
+ ```
39
+
40
+ Check claim status and token:
41
+
42
+ ```bash
43
+ enigmind-cli claim status
44
+ ```
45
+
46
+ ### JSON Config Example
47
+
48
+ ```json
49
+ {
50
+ "broker_url": "mqtts://broker.enigmind.ai:8883",
51
+ "claim": {
52
+ "base_url": "https://api.enigmind.ai",
53
+ "poll_interval_s": 5,
54
+ "timeout_s": 300
55
+ }
56
+ }
57
+ ```
58
+
59
+ ## Installation & Setup
60
+
61
+ ```powershell
62
+ py -3.12 -m venv .venv
63
+ .venv\Scripts\Activate.ps1
64
+ python -m pip install -U pip
65
+ pip install -e .[dev]
66
+ ```
67
+
68
+ Optional Health plugin:
69
+
70
+ ```powershell
71
+ pip install -e .[health]
72
+ ```
73
+
74
+ ## CLI
75
+
76
+ ```bash
77
+ enigmind-cli send --data '{"temp": 22.4}' --config ./enigmind.json
78
+ ```
79
+
80
+ ## Releasing
81
+
82
+ Releases are published to PyPI automatically via GitHub Actions when you create a GitHub Release.
83
+
84
+ 1. Bump `version` in `pyproject.toml`.
85
+ 2. Commit and push to `main`.
86
+ 3. Go to **GitHub → Releases → Draft a new release**.
87
+ 4. Create a new tag (e.g. `v0.1.0`), add release notes, and click **Publish release**.
88
+ 5. The `release.yml` workflow will build and publish the package to PyPI.
89
+
90
+ ## Plugins
91
+
92
+ - Health plugin: CPU/memory/disk metrics (requires optional dependency `psutil`).
93
+ - Geo plugin: local geofencing hooks (no extra dependencies).
@@ -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