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.
- enigmind_agent/__init__.py +16 -0
- enigmind_agent/agent.py +162 -0
- enigmind_agent/buffer.py +80 -0
- enigmind_agent/claim.py +247 -0
- enigmind_agent/cli.py +128 -0
- enigmind_agent/config.py +158 -0
- enigmind_agent/delta.py +18 -0
- enigmind_agent/mqtt.py +92 -0
- enigmind_agent/plugins/__init__.py +5 -0
- enigmind_agent/plugins/base.py +14 -0
- enigmind_agent/plugins/geo.py +78 -0
- enigmind_agent/plugins/health.py +24 -0
- enigmind_agent/rpc.py +34 -0
- enigmind_agent/storage.py +62 -0
- enigmind_agent/version.py +1 -0
- enigmind_agent-0.1.0.dist-info/METADATA +120 -0
- enigmind_agent-0.1.0.dist-info/RECORD +20 -0
- enigmind_agent-0.1.0.dist-info/WHEEL +5 -0
- enigmind_agent-0.1.0.dist-info/entry_points.txt +2 -0
- enigmind_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
enigmind_agent/agent.py
ADDED
|
@@ -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)
|
enigmind_agent/buffer.py
ADDED
|
@@ -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
|
enigmind_agent/claim.py
ADDED
|
@@ -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()
|
enigmind_agent/config.py
ADDED
|
@@ -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()
|
enigmind_agent/delta.py
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
enigmind_agent
|