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.
- enigmind_agent-0.1.0/PKG-INFO +120 -0
- enigmind_agent-0.1.0/README.md +93 -0
- enigmind_agent-0.1.0/enigmind_agent/__init__.py +16 -0
- enigmind_agent-0.1.0/enigmind_agent/agent.py +162 -0
- enigmind_agent-0.1.0/enigmind_agent/buffer.py +80 -0
- enigmind_agent-0.1.0/enigmind_agent/claim.py +247 -0
- enigmind_agent-0.1.0/enigmind_agent/cli.py +128 -0
- enigmind_agent-0.1.0/enigmind_agent/config.py +158 -0
- enigmind_agent-0.1.0/enigmind_agent/delta.py +18 -0
- enigmind_agent-0.1.0/enigmind_agent/mqtt.py +92 -0
- enigmind_agent-0.1.0/enigmind_agent/plugins/__init__.py +5 -0
- enigmind_agent-0.1.0/enigmind_agent/plugins/base.py +14 -0
- enigmind_agent-0.1.0/enigmind_agent/plugins/geo.py +78 -0
- enigmind_agent-0.1.0/enigmind_agent/plugins/health.py +24 -0
- enigmind_agent-0.1.0/enigmind_agent/rpc.py +34 -0
- enigmind_agent-0.1.0/enigmind_agent/storage.py +62 -0
- enigmind_agent-0.1.0/enigmind_agent/version.py +1 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/PKG-INFO +120 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/SOURCES.txt +28 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/dependency_links.txt +1 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/entry_points.txt +2 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/requires.txt +9 -0
- enigmind_agent-0.1.0/enigmind_agent.egg-info/top_level.txt +1 -0
- enigmind_agent-0.1.0/pyproject.toml +53 -0
- enigmind_agent-0.1.0/setup.cfg +4 -0
- enigmind_agent-0.1.0/tests/test_buffer.py +18 -0
- enigmind_agent-0.1.0/tests/test_claim.py +110 -0
- enigmind_agent-0.1.0/tests/test_delta.py +16 -0
- enigmind_agent-0.1.0/tests/test_mqtt.py +59 -0
- 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
|