edge-sync-sdk 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.
- edge_sync/__init__.py +7 -0
- edge_sync/client.py +287 -0
- edge_sync/integrations/__init__.py +6 -0
- edge_sync/integrations/raspberry_pi.py +122 -0
- edge_sync/integrations/solar_race.py +139 -0
- edge_sync/queue.py +81 -0
- edge_sync/sync_policy.py +49 -0
- edge_sync_sdk-0.1.0.dist-info/METADATA +395 -0
- edge_sync_sdk-0.1.0.dist-info/RECORD +11 -0
- edge_sync_sdk-0.1.0.dist-info/WHEEL +4 -0
- edge_sync_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
edge_sync/__init__.py
ADDED
edge_sync/client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Edge SDK public API and background sync pipeline.
|
|
2
|
+
|
|
3
|
+
What a developer using the SDK calls:
|
|
4
|
+
|
|
5
|
+
init(server_url, api_key, device_id)
|
|
6
|
+
track(metric, value, ts=None) -- returns immediately, never blocks
|
|
7
|
+
force_flush() -- drain everything now (e.g. on shutdown)
|
|
8
|
+
|
|
9
|
+
What happens after track():
|
|
10
|
+
|
|
11
|
+
1. The point is written to a durable SQLite queue (edge_sync/queue.py).
|
|
12
|
+
2. A background batcher pulls unsent points oldest-first and groups them.
|
|
13
|
+
3. The sender POSTs the batch to the REST server.
|
|
14
|
+
4. Points are marked sent only after the server acknowledges. Failed sends
|
|
15
|
+
retry with backoff, so a dropped connection just grows the backlog, which
|
|
16
|
+
then drains in chronological order once the network returns.
|
|
17
|
+
|
|
18
|
+
The pipeline is deliberately data-agnostic: it only ever sees metric/value/ts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from . import sync_policy
|
|
29
|
+
from .queue import Queue
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Client:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
server_url: str,
|
|
36
|
+
api_key: str,
|
|
37
|
+
device_id: str,
|
|
38
|
+
*,
|
|
39
|
+
db_path: str = "sdk_outbox.db",
|
|
40
|
+
metadata: dict | None = None,
|
|
41
|
+
batch_size: int = 50,
|
|
42
|
+
flush_interval: float = 2.0,
|
|
43
|
+
max_backoff: float = 30.0,
|
|
44
|
+
network: str | None = None,
|
|
45
|
+
battery: float | None = None,
|
|
46
|
+
sender=None,
|
|
47
|
+
):
|
|
48
|
+
self.server_url = server_url.rstrip("/")
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
self.device_id = device_id
|
|
51
|
+
self.metadata = metadata or {}
|
|
52
|
+
self.max_backoff = max_backoff
|
|
53
|
+
|
|
54
|
+
# Link conditions drive the network-aware sync policy. When `network` is
|
|
55
|
+
# set, batch_size / flush_interval are derived from it dynamically;
|
|
56
|
+
# otherwise the fixed values passed in are used.
|
|
57
|
+
self.network = network
|
|
58
|
+
self.battery = battery
|
|
59
|
+
self._base_batch_size = batch_size
|
|
60
|
+
self._base_flush_interval = flush_interval
|
|
61
|
+
self.batch_size = batch_size # current effective values
|
|
62
|
+
self.flush_interval = flush_interval
|
|
63
|
+
self._apply_policy()
|
|
64
|
+
|
|
65
|
+
self.queue = Queue(db_path)
|
|
66
|
+
|
|
67
|
+
# The sender is injectable so tests can simulate a flaky/offline network.
|
|
68
|
+
# It takes a batch dict and must raise on failure, return on success.
|
|
69
|
+
self._sender = sender or self._http_send
|
|
70
|
+
self._http = httpx.Client(timeout=10.0) if sender is None else None
|
|
71
|
+
|
|
72
|
+
self._seq = 0
|
|
73
|
+
self._seq_lock = threading.Lock()
|
|
74
|
+
|
|
75
|
+
self._stop = threading.Event()
|
|
76
|
+
self._wake = threading.Event() # nudges the batcher to flush now
|
|
77
|
+
self._worker = threading.Thread(target=self._run, daemon=True)
|
|
78
|
+
self._worker.start()
|
|
79
|
+
|
|
80
|
+
# --- public API ---------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def track(self, metric: str, value: float, ts: int | None = None) -> str:
|
|
83
|
+
"""Inject one data point. Persists it durably and returns immediately."""
|
|
84
|
+
if ts is None:
|
|
85
|
+
ts = int(time.time() * 1000)
|
|
86
|
+
point_id = self._next_id(ts)
|
|
87
|
+
self.queue.enqueue(point_id, metric, float(value), ts)
|
|
88
|
+
# If we've hit a full batch, nudge the worker instead of waiting.
|
|
89
|
+
if self.queue.unsent_count() >= self.batch_size:
|
|
90
|
+
self._wake.set()
|
|
91
|
+
return point_id
|
|
92
|
+
|
|
93
|
+
def force_flush(self, timeout: float = 15.0) -> bool:
|
|
94
|
+
"""Block until the queue is fully drained or `timeout` elapses.
|
|
95
|
+
|
|
96
|
+
Returns True if everything was acknowledged, False on timeout.
|
|
97
|
+
"""
|
|
98
|
+
deadline = time.time() + timeout
|
|
99
|
+
while time.time() < deadline:
|
|
100
|
+
if self.queue.unsent_count() == 0:
|
|
101
|
+
return True
|
|
102
|
+
self._wake.set()
|
|
103
|
+
time.sleep(0.05)
|
|
104
|
+
return self.queue.unsent_count() == 0
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
"""Stop the background worker and release resources."""
|
|
108
|
+
self._stop.set()
|
|
109
|
+
self._wake.set()
|
|
110
|
+
self._worker.join(timeout=5.0)
|
|
111
|
+
if self._http is not None:
|
|
112
|
+
self._http.close()
|
|
113
|
+
self.queue.close()
|
|
114
|
+
|
|
115
|
+
# --- network-aware sync policy ------------------------------------------
|
|
116
|
+
|
|
117
|
+
def set_link(self, network: str | None = None, battery: float | None = None) -> None:
|
|
118
|
+
"""Update the device's link conditions; the batcher adapts immediately."""
|
|
119
|
+
if network is not None:
|
|
120
|
+
self.network = network
|
|
121
|
+
if battery is not None:
|
|
122
|
+
self.battery = battery
|
|
123
|
+
self._apply_policy()
|
|
124
|
+
self._wake.set() # re-evaluate the loop now
|
|
125
|
+
|
|
126
|
+
def _apply_policy(self) -> None:
|
|
127
|
+
"""Recompute effective batch size / flush interval from link state."""
|
|
128
|
+
if self.network is None:
|
|
129
|
+
self.batch_size = self._base_batch_size
|
|
130
|
+
self.flush_interval = self._base_flush_interval
|
|
131
|
+
self._allow_send = True
|
|
132
|
+
else:
|
|
133
|
+
self.batch_size, self.flush_interval, self._allow_send = sync_policy.plan(
|
|
134
|
+
self.network, self.battery
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
def _send_metadata(self) -> dict:
|
|
138
|
+
"""Static metadata plus current link state, sent once per batch."""
|
|
139
|
+
meta = dict(self.metadata)
|
|
140
|
+
if self.network is not None:
|
|
141
|
+
meta["network"] = self.network
|
|
142
|
+
if self.battery is not None:
|
|
143
|
+
meta["battery"] = self.battery
|
|
144
|
+
return meta
|
|
145
|
+
|
|
146
|
+
# --- id assignment ------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _next_id(self, ts: int) -> str:
|
|
149
|
+
# device + sequence + timestamp -> globally unique, client-assigned.
|
|
150
|
+
# This is what makes server ingestion idempotent.
|
|
151
|
+
with self._seq_lock:
|
|
152
|
+
self._seq += 1
|
|
153
|
+
seq = self._seq
|
|
154
|
+
return f"{self.device_id}-{seq:06d}-{ts}"
|
|
155
|
+
|
|
156
|
+
# --- background batcher + sender ----------------------------------------
|
|
157
|
+
|
|
158
|
+
def _run(self) -> None:
|
|
159
|
+
backoff = 1.0
|
|
160
|
+
while not self._stop.is_set():
|
|
161
|
+
# Flush on a timer or when nudged (full batch / force_flush / close).
|
|
162
|
+
self._wake.wait(timeout=self.flush_interval)
|
|
163
|
+
self._wake.clear()
|
|
164
|
+
|
|
165
|
+
# Network-aware policy: when the device knows it's offline, don't
|
|
166
|
+
# attempt to send -- just keep buffering durably (saves the radio).
|
|
167
|
+
if not self._allow_send:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
sent_any = True
|
|
171
|
+
while sent_any and not self._stop.is_set():
|
|
172
|
+
points = self.queue.fetch_unsent(self.batch_size)
|
|
173
|
+
if not points:
|
|
174
|
+
sent_any = False
|
|
175
|
+
backoff = 1.0
|
|
176
|
+
break
|
|
177
|
+
try:
|
|
178
|
+
self._send_batch(points)
|
|
179
|
+
self.queue.mark_sent([p["id"] for p in points])
|
|
180
|
+
backoff = 1.0
|
|
181
|
+
except Exception:
|
|
182
|
+
# Network/server failure: leave points unsent and back off.
|
|
183
|
+
# They stay safely in the queue and are retried next loop.
|
|
184
|
+
self._sleep_backoff(backoff)
|
|
185
|
+
backoff = min(backoff * 2, self.max_backoff)
|
|
186
|
+
sent_any = False
|
|
187
|
+
|
|
188
|
+
# On close, make a best-effort final drain of whatever is queued.
|
|
189
|
+
self._drain_remaining()
|
|
190
|
+
|
|
191
|
+
def _send_batch(self, points: list[dict]) -> None:
|
|
192
|
+
batch = {
|
|
193
|
+
"device_id": self.device_id,
|
|
194
|
+
"metadata": self._send_metadata(),
|
|
195
|
+
"points": points,
|
|
196
|
+
}
|
|
197
|
+
self._sender(batch)
|
|
198
|
+
|
|
199
|
+
def _http_send(self, batch: dict) -> None:
|
|
200
|
+
resp = self._http.post(
|
|
201
|
+
f"{self.server_url}/api/v1/telemetry",
|
|
202
|
+
json=batch,
|
|
203
|
+
headers={"X-API-Key": self.api_key},
|
|
204
|
+
)
|
|
205
|
+
resp.raise_for_status()
|
|
206
|
+
|
|
207
|
+
def _sleep_backoff(self, seconds: float) -> None:
|
|
208
|
+
# Interruptible sleep so close()/force_flush() stay responsive.
|
|
209
|
+
self._wake.wait(timeout=seconds)
|
|
210
|
+
self._wake.clear()
|
|
211
|
+
|
|
212
|
+
def _drain_remaining(self) -> None:
|
|
213
|
+
try:
|
|
214
|
+
points = self.queue.fetch_unsent(self.batch_size)
|
|
215
|
+
while points:
|
|
216
|
+
self._send_batch(points)
|
|
217
|
+
self.queue.mark_sent([p["id"] for p in points])
|
|
218
|
+
points = self.queue.fetch_unsent(self.batch_size)
|
|
219
|
+
except Exception:
|
|
220
|
+
pass # best effort; unsent data remains durably on disk
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --- module-level convenience API ------------------------------------------
|
|
224
|
+
# Mirrors the architecture's public surface: init / track / force_flush.
|
|
225
|
+
|
|
226
|
+
_default: Client | None = None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def init(server_url: str, api_key: str, device_id: str, **kwargs) -> Client:
|
|
230
|
+
global _default
|
|
231
|
+
_default = Client(server_url, api_key, device_id, **kwargs)
|
|
232
|
+
return _default
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def auto_init(config_path: str | None = None) -> Client:
|
|
236
|
+
"""Initialize the SDK without hand-coding init() — read config from, in order:
|
|
237
|
+
|
|
238
|
+
1. an explicit JSON file at `config_path`,
|
|
239
|
+
2. a `telemetry.json` file in the current directory (if present),
|
|
240
|
+
3. environment variables: TELEMETRY_SERVER_URL, TELEMETRY_API_KEY,
|
|
241
|
+
TELEMETRY_DEVICE_ID, and optional TELEMETRY_NETWORK.
|
|
242
|
+
|
|
243
|
+
A config file may set: server_url, api_key, device_id, network, and any other
|
|
244
|
+
Client option (batch_size, flush_interval, db_path, metadata, …). Handy on a
|
|
245
|
+
device that's provisioned once with a key from the dashboard's Setup tab.
|
|
246
|
+
"""
|
|
247
|
+
import json
|
|
248
|
+
import os
|
|
249
|
+
|
|
250
|
+
cfg: dict = {}
|
|
251
|
+
path = config_path or ("telemetry.json" if os.path.exists("telemetry.json") else None)
|
|
252
|
+
if path:
|
|
253
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
254
|
+
cfg = json.load(f)
|
|
255
|
+
|
|
256
|
+
server_url = cfg.get("server_url") or os.environ.get("TELEMETRY_SERVER_URL")
|
|
257
|
+
api_key = cfg.get("api_key") or os.environ.get("TELEMETRY_API_KEY")
|
|
258
|
+
device_id = cfg.get("device_id") or os.environ.get("TELEMETRY_DEVICE_ID")
|
|
259
|
+
network = cfg.get("network") or os.environ.get("TELEMETRY_NETWORK")
|
|
260
|
+
|
|
261
|
+
missing = [n for n, v in
|
|
262
|
+
(("server_url", server_url), ("api_key", api_key), ("device_id", device_id))
|
|
263
|
+
if not v]
|
|
264
|
+
if missing:
|
|
265
|
+
raise RuntimeError(
|
|
266
|
+
"auto_init() is missing: " + ", ".join(missing) +
|
|
267
|
+
". Provide them in a config file or TELEMETRY_* environment variables."
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Pass through any extra Client options from the config file.
|
|
271
|
+
opts = {k: v for k, v in cfg.items()
|
|
272
|
+
if k not in ("server_url", "api_key", "device_id")}
|
|
273
|
+
if network and "network" not in opts:
|
|
274
|
+
opts["network"] = network
|
|
275
|
+
return init(server_url, api_key, device_id, **opts)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def track(metric: str, value: float, ts: int | None = None) -> str:
|
|
279
|
+
if _default is None:
|
|
280
|
+
raise RuntimeError("SDK not initialized; call init() first")
|
|
281
|
+
return _default.track(metric, value, ts)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def force_flush(timeout: float = 15.0) -> bool:
|
|
285
|
+
if _default is None:
|
|
286
|
+
raise RuntimeError("SDK not initialized; call init() first")
|
|
287
|
+
return _default.force_flush(timeout)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Raspberry Pi system-metrics integration — a hardware-free starting point.
|
|
2
|
+
|
|
3
|
+
Reads the Pi's own health from the OS (CPU temperature, load, memory, uptime)
|
|
4
|
+
and feeds it to the SDK. It needs **no extra sensors**, so it runs on any
|
|
5
|
+
Raspberry Pi out of the box — useful on its own, and a demonstration that the
|
|
6
|
+
SDK serves *any* Pi project, not one specific device.
|
|
7
|
+
|
|
8
|
+
Reads are best-effort: anything the platform doesn't expose (e.g. off a Pi) is
|
|
9
|
+
simply skipped, so this never crashes on a dev laptop. Standard library only.
|
|
10
|
+
|
|
11
|
+
from edge_sync.client import auto_init
|
|
12
|
+
from edge_sync.integrations.raspberry_pi import SystemMonitor
|
|
13
|
+
|
|
14
|
+
auto_init()
|
|
15
|
+
SystemMonitor(client=None, hz=1).start() # streams cpu_temp_C, load, mem…
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
|
|
24
|
+
from ..client import track as _default_track
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_cpu_temp_c():
|
|
28
|
+
"""CPU temperature in °C from the thermal zone, or None if unavailable."""
|
|
29
|
+
try:
|
|
30
|
+
with open("/sys/class/thermal/thermal_zone0/temp") as f:
|
|
31
|
+
return round(int(f.read().strip()) / 1000.0, 1)
|
|
32
|
+
except Exception:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_loadavg():
|
|
37
|
+
"""(1m, 5m, 15m) load averages, or None (os.getloadavg is Unix-only)."""
|
|
38
|
+
try:
|
|
39
|
+
return os.getloadavg()
|
|
40
|
+
except (OSError, AttributeError):
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_mem_used_percent():
|
|
45
|
+
"""Used memory as a percentage from /proc/meminfo, or None."""
|
|
46
|
+
try:
|
|
47
|
+
info = {}
|
|
48
|
+
with open("/proc/meminfo") as f:
|
|
49
|
+
for line in f:
|
|
50
|
+
key, _, rest = line.partition(":")
|
|
51
|
+
info[key] = int(rest.strip().split()[0]) # value is in kB
|
|
52
|
+
total, avail = info.get("MemTotal"), info.get("MemAvailable")
|
|
53
|
+
if total and avail is not None:
|
|
54
|
+
return round((total - avail) / total * 100.0, 1)
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_uptime_s():
|
|
61
|
+
"""Uptime in seconds from /proc/uptime, or None."""
|
|
62
|
+
try:
|
|
63
|
+
with open("/proc/uptime") as f:
|
|
64
|
+
return round(float(f.read().split()[0]), 0)
|
|
65
|
+
except Exception:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def read_system_metrics() -> dict:
|
|
70
|
+
"""A dict of the Pi system metrics available on this host (skips missing)."""
|
|
71
|
+
metrics = {}
|
|
72
|
+
temp = _read_cpu_temp_c()
|
|
73
|
+
if temp is not None:
|
|
74
|
+
metrics["cpu_temp_C"] = temp
|
|
75
|
+
load = _read_loadavg()
|
|
76
|
+
if load is not None:
|
|
77
|
+
metrics["cpu_load_1m"] = round(load[0], 2)
|
|
78
|
+
metrics["cpu_load_5m"] = round(load[1], 2)
|
|
79
|
+
mem = _read_mem_used_percent()
|
|
80
|
+
if mem is not None:
|
|
81
|
+
metrics["mem_used_percent"] = mem
|
|
82
|
+
uptime = _read_uptime_s()
|
|
83
|
+
if uptime is not None:
|
|
84
|
+
metrics["uptime_s"] = uptime
|
|
85
|
+
return metrics
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def track_system_metrics(client=None, ts: int | None = None) -> int:
|
|
89
|
+
"""Read the Pi's system metrics and hand each to the SDK via track()."""
|
|
90
|
+
track = client.track if client is not None else _default_track
|
|
91
|
+
n = 0
|
|
92
|
+
for name, value in read_system_metrics().items():
|
|
93
|
+
track(name, float(value), ts)
|
|
94
|
+
n += 1
|
|
95
|
+
return n
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SystemMonitor:
|
|
99
|
+
"""Polls the Pi's system metrics at `hz` and streams them through the SDK.
|
|
100
|
+
|
|
101
|
+
Drop-in like the solar-car simulator, but for the Pi itself — no hardware.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, client, hz: float = 1.0):
|
|
105
|
+
self.client = client
|
|
106
|
+
self.period = 1.0 / hz
|
|
107
|
+
self._stop = threading.Event()
|
|
108
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
109
|
+
|
|
110
|
+
def start(self) -> "SystemMonitor":
|
|
111
|
+
self._thread.start()
|
|
112
|
+
return self
|
|
113
|
+
|
|
114
|
+
def stop(self) -> None:
|
|
115
|
+
self._stop.set()
|
|
116
|
+
self._thread.join(timeout=2.0)
|
|
117
|
+
|
|
118
|
+
def _run(self) -> None:
|
|
119
|
+
while not self._stop.is_set():
|
|
120
|
+
# One device timestamp per sample, shared by all its metrics.
|
|
121
|
+
track_system_metrics(self.client, ts=int(time.time() * 1000))
|
|
122
|
+
time.sleep(self.period)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""SolarRace-OS integration for the Edge-Sync SDK.
|
|
2
|
+
|
|
3
|
+
The solar car's Raspberry Pi reads the CAN bus and decodes it into a nested
|
|
4
|
+
`vehicle_state` dict (BMS / motor controller / battery-temp controller).
|
|
5
|
+
|
|
6
|
+
`track_vehicle_state()` takes that dict and hands every meaningful numeric signal
|
|
7
|
+
to the SDK via `track()`, which buffers it durably, batches it, and syncs it to
|
|
8
|
+
the REST server in order even across network dropouts. No CAN decoding happens
|
|
9
|
+
here — the Pi already did it.
|
|
10
|
+
|
|
11
|
+
# in your main loop, once you have a decoded vehicle_state:
|
|
12
|
+
from edge_sync.client import init
|
|
13
|
+
from edge_sync.integrations.solar_race import track_vehicle_state
|
|
14
|
+
|
|
15
|
+
init(SERVER_URL, API_KEY, device_id="solar-car-01", network="lte")
|
|
16
|
+
...
|
|
17
|
+
track_vehicle_state(self.vehicle_state) # <- one line, resilient
|
|
18
|
+
|
|
19
|
+
`SimulatedSolarCar` reproduces a realistic `vehicle_state` off-car (no hardware)
|
|
20
|
+
and feeds it through the very same bridge, so the demo exercises the real path.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import math
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
|
|
29
|
+
from ..client import track as _default_track
|
|
30
|
+
|
|
31
|
+
# Fields that are identifiers, counters, codes, or duplicates — not telemetry
|
|
32
|
+
# worth charting. Kept out so the dashboard stays a clean, meaningful set.
|
|
33
|
+
_SKIP_KEYS = {
|
|
34
|
+
"temp_module", "bms_balance_mask",
|
|
35
|
+
"bms_string_count", "bms_ntc_count", # static hardware specs
|
|
36
|
+
"bms_cycles", "bms_full_capacity_Ah", # rarely-changing / spec
|
|
37
|
+
"bms_error_code", "mms_error_code", "mms_limit_code", # bitmask codes, not series
|
|
38
|
+
"calculated_lap", # derived counter
|
|
39
|
+
"battery_temp_avg_C", "battery_temp_low_C", # duplicate of battery_temp_C / less useful
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def track_vehicle_state(vehicle_state: dict, client=None, ts: int | None = None) -> int:
|
|
44
|
+
"""Hand the meaningful numeric signals in a `vehicle_state` dict to the SDK.
|
|
45
|
+
|
|
46
|
+
Walks each section (battery / motor / temp_controller / …) and calls
|
|
47
|
+
`track(name, value, ts)` for each chartable numeric signal. To keep the
|
|
48
|
+
dashboard readable it does two clean-ups:
|
|
49
|
+
|
|
50
|
+
* the 30+ per-cell voltages (`bms_cell_01_V` …) are collapsed into a
|
|
51
|
+
compact **min / max / spread** summary — the numbers that actually
|
|
52
|
+
indicate pack health — instead of 30 separate series;
|
|
53
|
+
* identifiers, counters, bitmask codes, and duplicate fields are skipped
|
|
54
|
+
(see `_SKIP_KEYS`). Booleans and lists are skipped too.
|
|
55
|
+
|
|
56
|
+
Pass `ts` (the car's snapshot timestamp, epoch ms) so every signal in one
|
|
57
|
+
decoded frame shares the same device time — that's what keeps the history
|
|
58
|
+
chronologically correct, exactly like the car's own `timestamp` field. When
|
|
59
|
+
omitted, the SDK stamps each point at track() time.
|
|
60
|
+
|
|
61
|
+
Pass `client` to target a specific SDK instance; otherwise the module-level
|
|
62
|
+
SDK configured via `init()` is used. Returns how many points were tracked.
|
|
63
|
+
"""
|
|
64
|
+
track = client.track if client is not None else _default_track
|
|
65
|
+
n = 0
|
|
66
|
+
for section, signals in vehicle_state.items():
|
|
67
|
+
if not isinstance(signals, dict):
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
# Collapse per-cell voltages (bms_cell_01_V …) into a 3-number summary.
|
|
71
|
+
cells = [v for k, v in signals.items()
|
|
72
|
+
if k.startswith("bms_cell_") and isinstance(v, (int, float))
|
|
73
|
+
and not isinstance(v, bool)]
|
|
74
|
+
if cells:
|
|
75
|
+
track("bms_cell_min_V", round(min(cells), 3), ts)
|
|
76
|
+
track("bms_cell_max_V", round(max(cells), 3), ts)
|
|
77
|
+
track("bms_cell_spread_V", round(max(cells) - min(cells), 3), ts)
|
|
78
|
+
n += 3
|
|
79
|
+
|
|
80
|
+
for name, value in signals.items():
|
|
81
|
+
if name.startswith("bms_cell_") or name in _SKIP_KEYS or isinstance(value, bool):
|
|
82
|
+
continue
|
|
83
|
+
if isinstance(value, (int, float)):
|
|
84
|
+
track(name, float(value), ts)
|
|
85
|
+
n += 1
|
|
86
|
+
return n
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SimulatedSolarCar:
|
|
90
|
+
"""Off-car simulator: produces a SolarRace-OS-shaped `vehicle_state` and
|
|
91
|
+
feeds it through `track_vehicle_state()`, so `python scripts/run.py` on a laptop
|
|
92
|
+
looks like the real car. Several signals are tuned to cross the server's
|
|
93
|
+
alert thresholds so the Alerts panel has something to show.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, client, hz: float = 5.0):
|
|
97
|
+
self.client = client
|
|
98
|
+
self.period = 1.0 / hz
|
|
99
|
+
self._stop = threading.Event()
|
|
100
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
101
|
+
|
|
102
|
+
def start(self) -> "SimulatedSolarCar":
|
|
103
|
+
self._thread.start()
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def stop(self) -> None:
|
|
107
|
+
self._stop.set()
|
|
108
|
+
self._thread.join(timeout=2.0)
|
|
109
|
+
|
|
110
|
+
def _state(self, t: float) -> dict:
|
|
111
|
+
battery_temp = round(42 + 6 * math.sin(t / 7), 1) # ~36-48, crosses 45
|
|
112
|
+
return {
|
|
113
|
+
"battery": {
|
|
114
|
+
"bms_voltage_V": round(108 + 6 * math.sin(t / 20), 2),
|
|
115
|
+
"bms_current_A": round(18 + 12 * math.sin(t / 5), 2),
|
|
116
|
+
"bms_soc_percent": round(max(2.0, 100 - (t * 1.2) % 100), 1), # drains <20
|
|
117
|
+
"bms_temp_1_C": round(44 + 8 * math.sin(t / 13), 1), # crosses 50
|
|
118
|
+
"bms_temp_2_C": round(40 + 5 * math.sin(t / 15), 1),
|
|
119
|
+
},
|
|
120
|
+
"motor": {
|
|
121
|
+
"mms_rpm": int(2500 + 1800 * math.sin(t / 4)),
|
|
122
|
+
"mms_power_W": int(1500 + 1200 * math.sin(t / 4)),
|
|
123
|
+
"mms_temperature_C": round(72 + 15 * math.sin(t / 11), 1), # crosses 80
|
|
124
|
+
},
|
|
125
|
+
"temp_controller": {
|
|
126
|
+
"battery_temp_C": battery_temp,
|
|
127
|
+
"battery_temp_high_C": round(battery_temp + 3, 1),
|
|
128
|
+
"battery_temp_low_C": round(battery_temp - 3, 1),
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def _run(self) -> None:
|
|
133
|
+
t = 0.0
|
|
134
|
+
while not self._stop.is_set():
|
|
135
|
+
# One device timestamp per snapshot, shared by all its signals.
|
|
136
|
+
ts = int(time.time() * 1000)
|
|
137
|
+
track_vehicle_state(self._state(t), self.client, ts=ts)
|
|
138
|
+
t += self.period
|
|
139
|
+
time.sleep(self.period)
|
edge_sync/queue.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Durable local queue for the edge SDK.
|
|
2
|
+
|
|
3
|
+
Every tracked point is written to SQLite *before* anything tries to send it.
|
|
4
|
+
That is the whole durability story: if the network is down or the device
|
|
5
|
+
reboots mid-flight, the data is still on disk and gets drained later.
|
|
6
|
+
|
|
7
|
+
The queue is the only shared state between the caller's thread (which calls
|
|
8
|
+
`enqueue` via `track()`) and the background batcher thread (which calls
|
|
9
|
+
`fetch_unsent` / `mark_sent`), so every operation is guarded by a lock and uses
|
|
10
|
+
a single connection opened with check_same_thread=False.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Queue:
|
|
18
|
+
def __init__(self, db_path: str):
|
|
19
|
+
self.db_path = db_path
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
22
|
+
self._conn.row_factory = sqlite3.Row
|
|
23
|
+
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
24
|
+
self._conn.execute(
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS outbox (
|
|
27
|
+
id TEXT PRIMARY KEY, -- client-assigned point id
|
|
28
|
+
metric TEXT NOT NULL,
|
|
29
|
+
value REAL NOT NULL,
|
|
30
|
+
ts INTEGER NOT NULL, -- device timestamp (epoch ms)
|
|
31
|
+
sent INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
seq INTEGER -- monotonic insert order, for stable tiebreak
|
|
33
|
+
)
|
|
34
|
+
"""
|
|
35
|
+
)
|
|
36
|
+
self._conn.commit()
|
|
37
|
+
|
|
38
|
+
def enqueue(self, id: str, metric: str, value: float, ts: int) -> None:
|
|
39
|
+
"""Persist one point. INSERT OR IGNORE so a duplicate id is a no-op."""
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._conn.execute(
|
|
42
|
+
"INSERT OR IGNORE INTO outbox (id, metric, value, ts, sent, seq) "
|
|
43
|
+
"VALUES (?, ?, ?, ?, 0, (SELECT COALESCE(MAX(seq), 0) + 1 FROM outbox))",
|
|
44
|
+
(id, metric, value, ts),
|
|
45
|
+
)
|
|
46
|
+
self._conn.commit()
|
|
47
|
+
|
|
48
|
+
def fetch_unsent(self, limit: int) -> list[dict]:
|
|
49
|
+
"""Oldest-first batch of points not yet acknowledged by the server.
|
|
50
|
+
|
|
51
|
+
Ordered by device timestamp (then insert order) so the backlog drains in
|
|
52
|
+
chronological order the moment the network returns.
|
|
53
|
+
"""
|
|
54
|
+
with self._lock:
|
|
55
|
+
rows = self._conn.execute(
|
|
56
|
+
"SELECT id, metric, value, ts FROM outbox "
|
|
57
|
+
"WHERE sent = 0 ORDER BY ts ASC, seq ASC LIMIT ?",
|
|
58
|
+
(limit,),
|
|
59
|
+
).fetchall()
|
|
60
|
+
return [dict(r) for r in rows]
|
|
61
|
+
|
|
62
|
+
def mark_sent(self, ids: list[str]) -> None:
|
|
63
|
+
"""Mark points sent -- only ever called after the server acknowledges."""
|
|
64
|
+
if not ids:
|
|
65
|
+
return
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._conn.executemany(
|
|
68
|
+
"UPDATE outbox SET sent = 1 WHERE id = ?", [(i,) for i in ids]
|
|
69
|
+
)
|
|
70
|
+
self._conn.commit()
|
|
71
|
+
|
|
72
|
+
def unsent_count(self) -> int:
|
|
73
|
+
with self._lock:
|
|
74
|
+
(n,) = self._conn.execute(
|
|
75
|
+
"SELECT COUNT(*) FROM outbox WHERE sent = 0"
|
|
76
|
+
).fetchone()
|
|
77
|
+
return n
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._conn.close()
|
edge_sync/sync_policy.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Network-aware sync policy.
|
|
2
|
+
|
|
3
|
+
A cellular-aware device shouldn't sync the same way on Wi-Fi as it does on a
|
|
4
|
+
weak Edge link with a near-dead battery. This module maps the current link
|
|
5
|
+
conditions (network type + battery level) to two knobs the batcher uses:
|
|
6
|
+
|
|
7
|
+
* batch_size -- how many points to group per request
|
|
8
|
+
* flush_interval -- how often to attempt a send
|
|
9
|
+
|
|
10
|
+
The intent: on a fast link, send small batches often (low latency); on a slow
|
|
11
|
+
or expensive link, send larger batches less often (fewer round-trips, less
|
|
12
|
+
radio wake-up). On low battery, back off further. When the device knows it's
|
|
13
|
+
offline, don't even try -- just keep buffering durably.
|
|
14
|
+
|
|
15
|
+
This lives outside the pipeline so the policy is swappable and testable on its
|
|
16
|
+
own; the SDK core stays generic.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
# Faster / cheaper links -> smaller batches, more often (low latency). Slower /
|
|
22
|
+
# costlier links -> bigger batches, less often, so each expensive round-trip and
|
|
23
|
+
# radio wake-up carries more samples.
|
|
24
|
+
NETWORK_PROFILES = {
|
|
25
|
+
"wifi": {"batch_size": 25, "flush_interval": 1.0},
|
|
26
|
+
"5g": {"batch_size": 40, "flush_interval": 1.5},
|
|
27
|
+
"lte": {"batch_size": 50, "flush_interval": 2.0},
|
|
28
|
+
"3g": {"batch_size": 75, "flush_interval": 4.0},
|
|
29
|
+
"edge": {"batch_size": 100, "flush_interval": 6.0},
|
|
30
|
+
"offline": {"batch_size": 50, "flush_interval": 2.0}, # buffer only
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
LOW_BATTERY_PCT = 20
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def plan(network: str, battery: float | None = None) -> tuple[int, float, bool]:
|
|
37
|
+
"""Return (batch_size, flush_interval, allow_send) for the conditions."""
|
|
38
|
+
profile = NETWORK_PROFILES.get(network, NETWORK_PROFILES["lte"])
|
|
39
|
+
batch_size = profile["batch_size"]
|
|
40
|
+
flush_interval = profile["flush_interval"]
|
|
41
|
+
allow_send = network != "offline"
|
|
42
|
+
|
|
43
|
+
# Low battery: sync less aggressively, in fewer/larger bursts, to cut the
|
|
44
|
+
# number of energy-hungry radio wake-ups.
|
|
45
|
+
if battery is not None and battery < LOW_BATTERY_PCT:
|
|
46
|
+
flush_interval *= 2.0
|
|
47
|
+
batch_size = min(batch_size * 2, 200)
|
|
48
|
+
|
|
49
|
+
return batch_size, flush_interval, allow_send
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: edge-sync-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Resilient telemetry & edge-sync SDK for the Raspberry Pi — buffer sensor data locally and sync it reliably across network dropouts, in order, with no duplicates.
|
|
5
|
+
Project-URL: Homepage, https://github.com/leerosenblit/Telemetry-Edge-Sync-SDK
|
|
6
|
+
Project-URL: Repository, https://github.com/leerosenblit/Telemetry-Edge-Sync-SDK
|
|
7
|
+
Project-URL: Issues, https://github.com/leerosenblit/Telemetry-Edge-Sync-SDK/issues
|
|
8
|
+
Author-email: Lee Rosenblit <Lee.Rosenblit@au10tix.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 Lee Rosenblit
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Keywords: buffering,edge,iot,offline-first,raspberry-pi,rest,sqlite,sync,telemetry
|
|
32
|
+
Classifier: Development Status :: 4 - Beta
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Operating System :: OS Independent
|
|
36
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
44
|
+
Classifier: Topic :: System :: Monitoring
|
|
45
|
+
Classifier: Topic :: System :: Networking
|
|
46
|
+
Requires-Python: >=3.9
|
|
47
|
+
Requires-Dist: httpx>=0.24
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# Telemetry & Edge-Sync SDK
|
|
51
|
+
|
|
52
|
+
> A resilient telemetry & edge-sync SDK for the **Raspberry Pi** — buffer sensor data
|
|
53
|
+
> locally, sync it reliably across network dropouts, in correct time order, with no
|
|
54
|
+
> duplicates. Works with **any sensors**; ships with a solar-race-car integration as a
|
|
55
|
+
> real, proven example.
|
|
56
|
+
|
|
57
|
+
## Description
|
|
58
|
+
|
|
59
|
+
Lots of Raspberry Pi projects stream sensor data over a flaky link — a field weather
|
|
60
|
+
station, a robot, an environmental or industrial monitor, or a solar race car threading
|
|
61
|
+
through tunnels and dead zones. A naive "push to the cloud" loses every sample sent
|
|
62
|
+
during an outage.
|
|
63
|
+
|
|
64
|
+
This SDK is the fix. On the device it **buffers telemetry to a local SQLite queue
|
|
65
|
+
first**, batches it, and syncs it to a REST server — surviving network dropouts *and*
|
|
66
|
+
reboots. A web portal visualizes it live. Your code integrates in **three lines**
|
|
67
|
+
(`init` / `track` / `force_flush`); whoever watches the dashboard writes **zero**.
|
|
68
|
+
|
|
69
|
+
The pipeline is **sensor-agnostic** — it only moves `metric / value / timestamp`. Two
|
|
70
|
+
ready integrations show the range: a hardware-free **Raspberry Pi system-metrics** one
|
|
71
|
+
that runs on any Pi out of the box, and the **flagship solar race car** (BMS / motor /
|
|
72
|
+
battery-temp decoded from CAN).
|
|
73
|
+
|
|
74
|
+
> **The one guarantee:** no data is lost when the device loses signal, and it arrives in
|
|
75
|
+
> correct chronological order, with no duplicates.
|
|
76
|
+
|
|
77
|
+
## Install
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install edge-sync-sdk
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from edge_sync import init, track, force_flush
|
|
85
|
+
|
|
86
|
+
init("https://your-server.example.com", api_key="<key>", device_id="pi-01")
|
|
87
|
+
track("cpu_temp_C", 54.2) # any metric — non-blocking, persisted to disk
|
|
88
|
+
force_flush() # drain the queue before exit
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Requires Python 3.9+. The only dependency is [`httpx`](https://www.python-httpx.org/).
|
|
92
|
+
(To run the **server + dashboard** locally, clone the repo and `pip install -r
|
|
93
|
+
requirements.txt` — see [Quick start](#quick-start).)
|
|
94
|
+
|
|
95
|
+
## Features
|
|
96
|
+
|
|
97
|
+
- **Durable offline queue** — every reading is written to on-device SQLite *before* any
|
|
98
|
+
send, so dropouts and reboots lose nothing.
|
|
99
|
+
- **Smart batching** — many samples per request (by count or time): fewer round-trips,
|
|
100
|
+
less radio/battery cost.
|
|
101
|
+
- **Auto-sync recovery** — backlog drains **oldest-first** the moment the link returns.
|
|
102
|
+
- **Ordered, idempotent ingestion** — points carry a client-assigned id; the server
|
|
103
|
+
upserts on it, so retries never duplicate. Stored by **device time**, not arrival time.
|
|
104
|
+
- **Network-aware sync policy** — batch size & frequency adapt to link type and battery.
|
|
105
|
+
- **Server-side alert rule engine** — threshold rules over your signals, editable
|
|
106
|
+
live from the portal.
|
|
107
|
+
- **API keys** — generate/revoke keys from the dashboard; the SDK can `auto_init()` from
|
|
108
|
+
config or environment.
|
|
109
|
+
- **Pit-wall portal** — live charts, device health, alerts, rules editor, device setup.
|
|
110
|
+
- **Your own backend** — plain REST to your own server, no vendor lock-in and no cloud
|
|
111
|
+
credentials on the car.
|
|
112
|
+
|
|
113
|
+
## Screenshots
|
|
114
|
+
|
|
115
|
+
**Overview — live charts**
|
|
116
|
+
|
|
117
|
+

|
|
118
|
+
|
|
119
|
+
**Alerts**
|
|
120
|
+
|
|
121
|
+

|
|
122
|
+
|
|
123
|
+
**Rules editor**
|
|
124
|
+
|
|
125
|
+

|
|
126
|
+
|
|
127
|
+
## Video
|
|
128
|
+
|
|
129
|
+
▶ **[Watch the demo walkthrough ](https://github.com/leerosenblit/Telemetry-Edge-Sync-SDK/issues/1)**
|
|
130
|
+
|
|
131
|
+
<!-- To embed an inline PLAYER instead of a link: in issue #1, right-click the video ->
|
|
132
|
+
"Copy link address" to get its asset URL (https://github.com/.../assets/.../….mp4),
|
|
133
|
+
then paste that URL on its own line here, replacing the link line above. -->
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Data model (JSON)
|
|
138
|
+
|
|
139
|
+
A **telemetry point** the SDK produces:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{ "id": "solar-car-01-000042-1718900000123", "metric": "battery_temp_C", "value": 46.2, "ts": 1718900000123 }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`id` = `device-sequence-timestamp` (client-assigned → enables idempotent ingestion).
|
|
146
|
+
`ts` is the **device** timestamp in epoch ms (the source of truth for ordering).
|
|
147
|
+
|
|
148
|
+
A **batch** the SDK POSTs (static metadata once, dynamic points many):
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"device_id": "solar-car-01",
|
|
153
|
+
"metadata": { "fw": "RaceOS-2.0", "type": "solar-car", "network": "lte" },
|
|
154
|
+
"points": [
|
|
155
|
+
{ "id": "solar-car-01-000001-1718900000100", "metric": "bms_voltage_V", "value": 108.4, "ts": 1718900000100 },
|
|
156
|
+
{ "id": "solar-car-01-000002-1718900000101", "metric": "bms_soc_percent", "value": 76.0, "ts": 1718900000101 }
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Database
|
|
162
|
+
|
|
163
|
+
Server storage is SQLite. The core table (full schema + ERD in
|
|
164
|
+
[docs/diagrams.md](docs/diagrams.md)):
|
|
165
|
+
|
|
166
|
+
```sql
|
|
167
|
+
CREATE TABLE telemetry (
|
|
168
|
+
id TEXT PRIMARY KEY, -- client-assigned -> idempotent upsert
|
|
169
|
+
device_id TEXT NOT NULL,
|
|
170
|
+
metric TEXT NOT NULL,
|
|
171
|
+
value REAL NOT NULL,
|
|
172
|
+
device_ts INTEGER NOT NULL, -- order + late-arrival by device time
|
|
173
|
+
received_ts INTEGER NOT NULL -- server arrival (diagnostics only)
|
|
174
|
+
);
|
|
175
|
+
-- sample row:
|
|
176
|
+
-- ('solar-car-01-000042-...', 'solar-car-01', 'battery_temp_C', 46.2, 1718900000123, 1718900000130)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Other tables: `device_meta` (latest metadata per device), `alerts` (rule breaches),
|
|
180
|
+
`rules` (editable alert rules), `api_keys` (issued keys). On the car, the SDK keeps a
|
|
181
|
+
durable `outbox` table.
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Public functions
|
|
186
|
+
|
|
187
|
+
What the car's software calls. Full reference + examples in
|
|
188
|
+
[docs/sdk-reference.md](docs/sdk-reference.md).
|
|
189
|
+
|
|
190
|
+
| Function | Purpose |
|
|
191
|
+
|---|---|
|
|
192
|
+
| `init(server_url, api_key, device_id, **opts)` | Configure the SDK (target, auth, identity, options). |
|
|
193
|
+
| `auto_init(config_path=None)` | Configure from a `telemetry.json` file or `TELEMETRY_*` env vars — no hand-coding. |
|
|
194
|
+
| `track(metric, value, ts=None)` | Record one data point. Non-blocking, durable. Returns the point id. |
|
|
195
|
+
| `force_flush(timeout=15.0)` | Block until the queue drains (e.g. before shutdown). |
|
|
196
|
+
| `Client(...)` | The SDK object behind the module API (use directly for multiple devices/instances). |
|
|
197
|
+
| `Client.set_link(network=, battery=)` | Update link conditions at runtime; the batcher adapts immediately. |
|
|
198
|
+
| `track_vehicle_state(vehicle_state, client=None, ts=None)` | Hand a SolarRace-OS dict to the SDK — tracks every numeric signal. |
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
from edge_sync.client import init, track, force_flush
|
|
202
|
+
init("https://your-server.example.com", api_key="<key>", device_id="pi-01")
|
|
203
|
+
track("cpu_temp_C", 54.2) # any metric — a sensor reading, a system stat, …
|
|
204
|
+
force_flush()
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Integrations
|
|
208
|
+
|
|
209
|
+
Device-specific bridges live in `edge_sync/integrations/` — write a small one for your
|
|
210
|
+
project, or just call `track()` directly. Two ship with the SDK:
|
|
211
|
+
|
|
212
|
+
| Integration | What it does |
|
|
213
|
+
|---|---|
|
|
214
|
+
| `raspberry_pi` | Streams the Pi's own system metrics (CPU temp, load, memory, uptime) — **no hardware needed**, runs on any Pi. |
|
|
215
|
+
| `solar_race` (flagship) | Hands a solar car's decoded `vehicle_state` (BMS / motor / battery-temp) to the SDK in one call. |
|
|
216
|
+
|
|
217
|
+
## Internal functions
|
|
218
|
+
|
|
219
|
+
The implementation behind the guarantee (see [docs/implementation.md](docs/implementation.md)).
|
|
220
|
+
|
|
221
|
+
| Where | Function | Role |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| `edge_sync/queue.py` | `Queue.enqueue / fetch_unsent / mark_sent` | Durable SQLite outbox; fetch oldest-first; mark sent only after ack. |
|
|
224
|
+
| `edge_sync/client.py` | `_run` | Background batcher loop: flush on timer/nudge, retry with exponential backoff. |
|
|
225
|
+
| `edge_sync/client.py` | `_send_batch / _http_send` | Package points into a batch and POST with `X-API-Key`. |
|
|
226
|
+
| `edge_sync/client.py` | `_apply_policy / _next_id` | Apply network policy; mint the client-assigned point id. |
|
|
227
|
+
| `edge_sync/sync_policy.py` | `plan(network, battery)` | Map link conditions → `(batch_size, flush_interval, allow_send)`. |
|
|
228
|
+
| `server/main.py` | `ingest / _valid_key / load_rules` | Auth, idempotent upsert, alert-rule evaluation. |
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
## Diagrams
|
|
233
|
+
|
|
234
|
+
(Also in [docs/diagrams.md](docs/diagrams.md). Mermaid renders on GitHub.)
|
|
235
|
+
|
|
236
|
+
### System architecture
|
|
237
|
+
|
|
238
|
+
```mermaid
|
|
239
|
+
flowchart LR
|
|
240
|
+
subgraph CAR["Solar car — Raspberry Pi"]
|
|
241
|
+
OS["SolarRace-OS<br/>decodes CAN → vehicle_state"] --> BRIDGE["track_vehicle_state()"]
|
|
242
|
+
subgraph SDK["Edge-Sync SDK"]
|
|
243
|
+
BRIDGE --> Q[("SQLite outbox")] --> BATCH["Batcher"] --> SEND["Sender (retry)"]
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
SEND -- "POST /api/v1/telemetry (X-API-Key)" --> API
|
|
247
|
+
subgraph SERVER["REST API server (FastAPI)"]
|
|
248
|
+
API["Ingest: auth · upsert · rules"] --> DB[("SQLite")]
|
|
249
|
+
end
|
|
250
|
+
DB --> READ["GET /metrics · /alerts · /devices"] -- "poll 1.5s" --> PORTAL["Pit-wall portal"]
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Entity-relationship diagram
|
|
254
|
+
|
|
255
|
+
```mermaid
|
|
256
|
+
erDiagram
|
|
257
|
+
OUTBOX ||..|| TELEMETRY : "syncs (by point id)"
|
|
258
|
+
TELEMETRY ||--o{ ALERTS : "breach raises"
|
|
259
|
+
RULES ||--o{ ALERTS : "evaluated into"
|
|
260
|
+
DEVICE_META ||--o{ TELEMETRY : "describes (device_id)"
|
|
261
|
+
API_KEYS ||..o{ TELEMETRY : "authenticates ingest"
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
> Full table-by-table ERD with columns is in [docs/diagrams.md](docs/diagrams.md).
|
|
265
|
+
|
|
266
|
+
### Sequence (track → chart, with offline recovery)
|
|
267
|
+
|
|
268
|
+
```mermaid
|
|
269
|
+
sequenceDiagram
|
|
270
|
+
autonumber
|
|
271
|
+
participant Pi as Car (SolarRace-OS)
|
|
272
|
+
participant SDK as Edge-Sync SDK
|
|
273
|
+
participant API as REST server
|
|
274
|
+
participant UI as Pit portal
|
|
275
|
+
Pi->>SDK: track_vehicle_state(vehicle_state, ts)
|
|
276
|
+
SDK->>SDK: enqueue to SQLite (durable)
|
|
277
|
+
loop batcher
|
|
278
|
+
alt network up
|
|
279
|
+
SDK->>API: POST /telemetry (batch)
|
|
280
|
+
API->>API: validate key · upsert · rules
|
|
281
|
+
API-->>SDK: 200 {accepted, alerts}
|
|
282
|
+
SDK->>SDK: mark_sent
|
|
283
|
+
else network down
|
|
284
|
+
SDK--xAPI: fails → backoff, keep buffering
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
UI->>API: GET /metrics (poll)
|
|
288
|
+
API-->>UI: points (device-time order) + alerts
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### State (the batcher)
|
|
292
|
+
|
|
293
|
+
```mermaid
|
|
294
|
+
stateDiagram-v2
|
|
295
|
+
[*] --> Idle
|
|
296
|
+
Idle --> Draining: timer / full / force_flush
|
|
297
|
+
Draining --> Sending: unsent exist
|
|
298
|
+
Draining --> Idle: empty
|
|
299
|
+
Sending --> Idle: 200 OK (mark_sent)
|
|
300
|
+
Sending --> Backoff: send failed
|
|
301
|
+
Backoff --> Sending: wait (1s→2s→4s…)
|
|
302
|
+
Idle --> BufferOnly: network = offline
|
|
303
|
+
BufferOnly --> Idle: link restored
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
---
|
|
307
|
+
|
|
308
|
+
## Quick start
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
pip install -r requirements.txt
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
**Run the server only** (no simulator — use this with a real Pi, and it's exactly what the
|
|
315
|
+
cloud runs):
|
|
316
|
+
|
|
317
|
+
```bash
|
|
318
|
+
uvicorn server.main:app --host 0.0.0.0 --port 8000
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Or run the all-in-one local demo** (server **+** a simulated solar car + opens the portal —
|
|
322
|
+
for trying it out with no hardware):
|
|
323
|
+
|
|
324
|
+
```bash
|
|
325
|
+
python scripts/run.py
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
- **Portal:** http://127.0.0.1:8000/ · **API docs (Swagger):** http://127.0.0.1:8000/docs
|
|
329
|
+
- In the portal's **Setup** tab, generate an API key — it shows a ready-to-paste
|
|
330
|
+
`auto_init()` snippet for your device. Full walkthrough: [docs/getting-started.md](docs/getting-started.md).
|
|
331
|
+
- **On a real Pi**, stream its own system metrics (no hardware) with
|
|
332
|
+
`python examples/pi_metrics.py`, or wire your sensors with three `track()` calls.
|
|
333
|
+
- To run the server **publicly** (so a remote Pi can reach it), deploy it with the
|
|
334
|
+
included [`render.yaml`](render.yaml) / [`Procfile`](Procfile).
|
|
335
|
+
|
|
336
|
+
Resilience demo: while data is streaming, stop and restart the server — the device keeps
|
|
337
|
+
buffering and drains the backlog **in order**, nothing lost.
|
|
338
|
+
|
|
339
|
+
## REST API
|
|
340
|
+
|
|
341
|
+
| Method & path | Role |
|
|
342
|
+
|---|---|
|
|
343
|
+
| `POST /api/v1/telemetry` | Ingest a batch (auth `X-API-Key`). Idempotent upsert by point id. |
|
|
344
|
+
| `GET /api/v1/metrics?device=&metric=&from=&to=` | Read points, ordered by device time. |
|
|
345
|
+
| `GET /api/v1/devices` | Per-device health + latest metadata. |
|
|
346
|
+
| `GET /api/v1/alerts?device=&limit=` | Recent alerts. |
|
|
347
|
+
| `GET/POST/PATCH/DELETE /api/v1/rules` | Manage alert rules. |
|
|
348
|
+
| `GET/POST/DELETE /api/v1/keys` | Issue / list / revoke API keys. |
|
|
349
|
+
|
|
350
|
+
Full reference: [docs/rest-api.md](docs/rest-api.md).
|
|
351
|
+
|
|
352
|
+
## Documentation
|
|
353
|
+
|
|
354
|
+
Full docs live in **[`docs/`](docs/)** (the permalink once pushed to GitHub):
|
|
355
|
+
[index](docs/index.md) · [use cases](docs/use-cases.md) · [features](docs/features.md) ·
|
|
356
|
+
[getting started](docs/getting-started.md) · [SDK reference](docs/sdk-reference.md) ·
|
|
357
|
+
[user init & API keys](docs/user-init.md) · [dashboard](docs/dashboard.md) ·
|
|
358
|
+
[implementation](docs/implementation.md) · [REST API](docs/rest-api.md) ·
|
|
359
|
+
[diagrams](docs/diagrams.md).
|
|
360
|
+
|
|
361
|
+
## Deploying to the cloud
|
|
362
|
+
|
|
363
|
+
To run the server publicly so a remote Pi (on cellular) can reach it, deploy to Render (or
|
|
364
|
+
any Procfile host) using the included [`render.yaml`](render.yaml) / [`Procfile`](Procfile).
|
|
365
|
+
|
|
366
|
+
## Project layout
|
|
367
|
+
|
|
368
|
+
```
|
|
369
|
+
edge_sync/ edge SDK: client (queue→batch→retry), durable queue, sync policy, auto_init
|
|
370
|
+
integrations/ device bridges — raspberry_pi.py (system metrics) + solar_race.py (flagship)
|
|
371
|
+
server/ FastAPI REST API + alert rule engine + API keys, serves the portal
|
|
372
|
+
dashboard/ single-page command-center portal (no build step, no CDN)
|
|
373
|
+
docs/ the documentation set (architecture, API reference, diagrams, …)
|
|
374
|
+
examples/ runnable demo.py + pi_metrics.py + the telemetry.json config template
|
|
375
|
+
scripts/ run.py — one-command launcher (server + simulated solar car)
|
|
376
|
+
tests/ test suite + shared conftest fixtures
|
|
377
|
+
pyproject.toml · requirements.txt project config + dependencies
|
|
378
|
+
Procfile · render.yaml · runtime.txt cloud-deploy config (Render)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## Testing
|
|
382
|
+
|
|
383
|
+
```bash
|
|
384
|
+
pytest
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Covers the core guarantees: no-loss across a network drop, idempotency on lost
|
|
388
|
+
acknowledgements, data survival across a process restart, the alert rule engine, the
|
|
389
|
+
network-aware sync policy, API-key validation, and `auto_init`.
|
|
390
|
+
|
|
391
|
+
## Design & scope
|
|
392
|
+
|
|
393
|
+
- [docs/architecture.md](docs/architecture.md) — full design and key engineering decisions.
|
|
394
|
+
- [docs/future-work.md](docs/future-work.md) — what's deferred (TSDB, message broker, Protobuf,
|
|
395
|
+
WebSocket push, multi-car fleet) and why, with the path to add each.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
edge_sync/__init__.py,sha256=OQ1clIJhuEPNNHbYO1UtZaEjtr_yEmWLK2GRRLeK6pI,193
|
|
2
|
+
edge_sync/client.py,sha256=Fz6nBMo5wINa434zF1cuK8UA-_E7vUo-HKnlXFCS-YM,10831
|
|
3
|
+
edge_sync/queue.py,sha256=CaNz_pMPs8nA23AxFbjwyTZ6YM6mz5C7LK0iL-Pv78g,3085
|
|
4
|
+
edge_sync/sync_policy.py,sha256=PaEfwhqyRmD_ddswssft04-eNo40EvZFxwlxzwsiA7g,2086
|
|
5
|
+
edge_sync/integrations/__init__.py,sha256=M-haAybvUMRESDNQ0-OewE6zX86aSgs8uS1JeNk3GWU,252
|
|
6
|
+
edge_sync/integrations/raspberry_pi.py,sha256=anoAxXv88xN-cKlzyCUygV-kmqcl5u2EYdgFHxWvuGo,3896
|
|
7
|
+
edge_sync/integrations/solar_race.py,sha256=TjMmVZZ11DOz7Vx9YdS13AcUrNwUrGCfY5jFtULBCmw,6030
|
|
8
|
+
edge_sync_sdk-0.1.0.dist-info/METADATA,sha256=43Dmut1tmdhEsaf4yQUkJvNahaK2SccYi-t3VPzuMk8,16370
|
|
9
|
+
edge_sync_sdk-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
edge_sync_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=GbvURq0hC1Cc6LFkw4MGZlUt-71bi5gx6BZT6rWup68,1070
|
|
11
|
+
edge_sync_sdk-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lee Rosenblit
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|