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 ADDED
@@ -0,0 +1,7 @@
1
+ """Telemetry & Edge-Sync edge SDK."""
2
+
3
+ from .client import Client, auto_init, force_flush, init, track
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["Client", "init", "auto_init", "track", "force_flush"]
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,6 @@
1
+ """Optional integrations that bridge a specific device to the generic SDK.
2
+
3
+ These are thin, device-specific helpers; the SDK core stays generic. The
4
+ solar-race helper, for example, flattens a SolarRace-OS `vehicle_state` dict
5
+ into `track()` calls.
6
+ """
@@ -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()
@@ -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
+ ![Overview](docs/media/overview.png)
118
+
119
+ **Alerts**
120
+
121
+ ![Alerts](docs/media/alerts.png)
122
+
123
+ **Rules editor**
124
+
125
+ ![Rules](docs/media/rules.png)
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.