plexus-python 0.11.0__tar.gz → 0.11.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {plexus_python-0.11.0 → plexus_python-0.11.2}/CHANGELOG.md +43 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/PKG-INFO +1 -1
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/__init__.py +1 -1
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/client.py +27 -6
- {plexus_python-0.11.0 → plexus_python-0.11.2}/pyproject.toml +1 -1
- {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus/SKILL.md +48 -3
- {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus-firmware/SKILL.md +39 -2
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_runs.py +42 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/uv.lock +1 -1
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/workflows/ci.yml +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.github/workflows/publish.yml +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/.gitignore +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/AGENTS.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/API.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/CODE_OF_CONDUCT.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/CONTRIBUTING.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/LICENSE +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/README.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/SECURITY.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/TODO.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/.python-version +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/README.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/basic.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/can.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/i2c_bme280.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mac_metrics.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mavlink.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/mqtt.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/pyproject.toml +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/thermal_camera.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/examples/uv.lock +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/_log.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/batching.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/buffer.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cameras/__init__.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cameras/thermal.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/cli.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/config.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/plexus/ws.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/plexus.service +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/release.sh +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/scan_buses.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/setup.sh +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/scripts/verify_skills.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/README.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/skills/plexus-dashboard/SKILL.md +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/conftest.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_basic.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_batching.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_batching_wire.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_buffer.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_config.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_isolation.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_rate_limit.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_retry.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_skills.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_thermal.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_video.py +0 -0
- {plexus_python-0.11.0 → plexus_python-0.11.2}/tests/test_ws.py +0 -0
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.11.2] - 2026-09-02 - Stamp run boundaries on the server's clock
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **A run's window was measured by a different clock than the points inside
|
|
10
|
+
it.** `_normalize_ts_ms` corrects generated timestamps by the offset the
|
|
11
|
+
gateway reports on connect; `start_run` / `end_run` read the local clock
|
|
12
|
+
instead. Every point taken in the final `offset` milliseconds of a run
|
|
13
|
+
therefore landed *after* `ended_at` and fell outside the window its own run
|
|
14
|
+
was evaluated over.
|
|
15
|
+
|
|
16
|
+
Measured on production: a device running 58ms behind the server sent 300
|
|
17
|
+
points and its run was judged on 284 of them. The shortfall reads exactly
|
|
18
|
+
like ingest lag, which is what it was mistaken for — a pass criterion can
|
|
19
|
+
come back green because the excursion that would have failed it was outside
|
|
20
|
+
the window by 58 milliseconds. Run boundaries now carry the same correction
|
|
21
|
+
as the telemetry. An explicitly passed `started_at` / `ended_at` is still
|
|
22
|
+
left alone.
|
|
23
|
+
|
|
24
|
+
## [0.11.1] - 2026-09-02 - Correct what the agent skills teach
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **`plexus-firmware` claimed the SDK batches. It does not.** Of CPython
|
|
29
|
+
targets — naming Jetson and embedded Linux specifically — the skill said
|
|
30
|
+
"stop and use the SDK instead ... It already does batching". `send()` is one
|
|
31
|
+
message per call, the gateway's ceiling counts messages, and the overflow is
|
|
32
|
+
discarded asynchronously after the send returned True. The skills are how a
|
|
33
|
+
coding agent learns this API instead of inventing it, so that line aimed
|
|
34
|
+
agents at silent data loss on exactly the hardware most likely to hit bench
|
|
35
|
+
rates. Both skills now carry the real ceilings and point at `px.batch()`.
|
|
36
|
+
|
|
37
|
+
- **Runs were documented in no skill**, so an agent had no way to know a test
|
|
38
|
+
bench should name its window. The `plexus` skill now carries the contract:
|
|
39
|
+
the app host rather than the gateway, offset-bearing timestamps, the
|
|
40
|
+
operators, and the two semantics that surprise people — every sample must
|
|
41
|
+
satisfy a criterion, and a metric with no data in the window fails rather
|
|
42
|
+
than passes.
|
|
43
|
+
|
|
44
|
+
- **The documented slug rule was wrong** (`^[a-z0-9][a-z0-9_-]{1,62}$`). The
|
|
45
|
+
gateway accepts dots, single characters and 256 bytes, so an agent applying
|
|
46
|
+
the documented rule would reject slugs the platform takes.
|
|
47
|
+
|
|
5
48
|
## [0.11.0] - 2026-09-01 - Batching, runs, and an end to silent drops
|
|
6
49
|
|
|
7
50
|
### Added
|
|
@@ -60,10 +60,12 @@ if TYPE_CHECKING: # pragma: no cover - typing only
|
|
|
60
60
|
logger = logging.getLogger(__name__)
|
|
61
61
|
|
|
62
62
|
|
|
63
|
-
def
|
|
64
|
-
"""
|
|
65
|
-
return
|
|
66
|
-
|
|
63
|
+
def _iso_from_ms(ms: int) -> str:
|
|
64
|
+
"""Epoch milliseconds as ISO-8601 with a Z offset — /api/runs validates the shape."""
|
|
65
|
+
return (
|
|
66
|
+
datetime.fromtimestamp(ms / 1000, timezone.utc)
|
|
67
|
+
.isoformat(timespec="milliseconds")
|
|
68
|
+
.replace("+00:00", "Z")
|
|
67
69
|
)
|
|
68
70
|
|
|
69
71
|
|
|
@@ -350,6 +352,22 @@ class Plexus:
|
|
|
350
352
|
return int(timestamp * 1000)
|
|
351
353
|
return int(timestamp)
|
|
352
354
|
|
|
355
|
+
def _now_iso(self) -> str:
|
|
356
|
+
"""Now on the SERVER's clock, as ISO-8601.
|
|
357
|
+
|
|
358
|
+
Run boundaries have to be stamped on the same clock as the telemetry
|
|
359
|
+
they bound, and `_normalize_ts_ms` corrects generated timestamps by the
|
|
360
|
+
offset the gateway reports on connect. Reading the local clock here
|
|
361
|
+
instead — which this did — leaves `ended_at` behind every point taken
|
|
362
|
+
in the final `offset` milliseconds of the run, and those points then
|
|
363
|
+
fall outside the window their own run is evaluated over.
|
|
364
|
+
|
|
365
|
+
The failure is quiet and looks like something else entirely: on a
|
|
366
|
+
device running 58ms behind the server, a 300-point run was evaluated
|
|
367
|
+
over 284 of them, and the shortfall reads exactly like ingest lag.
|
|
368
|
+
"""
|
|
369
|
+
return _iso_from_ms(int(time.time() * 1000) + self._clock_offset_ms)
|
|
370
|
+
|
|
353
371
|
@staticmethod
|
|
354
372
|
def _infer_class(value: FlexValue) -> str:
|
|
355
373
|
"""Numbers are metrics; everything else (str/bool/dict/list) is an event.
|
|
@@ -584,7 +602,10 @@ class Plexus:
|
|
|
584
602
|
Returns:
|
|
585
603
|
The created run dict, including its "id".
|
|
586
604
|
"""
|
|
587
|
-
body: dict[str, Any] = {
|
|
605
|
+
body: dict[str, Any] = {
|
|
606
|
+
"name": name,
|
|
607
|
+
"started_at": started_at or self._now_iso(),
|
|
608
|
+
}
|
|
588
609
|
if source_id is not _USE_CLIENT_SOURCE:
|
|
589
610
|
body["source_id"] = source_id
|
|
590
611
|
else:
|
|
@@ -664,7 +685,7 @@ class Plexus:
|
|
|
664
685
|
return self._api(
|
|
665
686
|
"PATCH",
|
|
666
687
|
f"/api/runs/{run_id}",
|
|
667
|
-
{"status": status, "ended_at": ended_at or
|
|
688
|
+
{"status": status, "ended_at": ended_at or self._now_iso()},
|
|
668
689
|
)["run"]
|
|
669
690
|
|
|
670
691
|
@contextmanager
|
|
@@ -156,12 +156,54 @@ When asked to "set up Plexus" in a project, do this:
|
|
|
156
156
|
5. **Add a single usage example** in the project — one obvious place, not five — so they can verify it works.
|
|
157
157
|
6. **Update README** with a short section: "Set `PLEXUS_API_KEY`, then run X."
|
|
158
158
|
|
|
159
|
+
## Runs
|
|
160
|
+
|
|
161
|
+
A **run** is a named time window on a source — a hot-fire, a bench sequence, a
|
|
162
|
+
flight. Runs are recalled on `/runs`, compared against each other aligned at
|
|
163
|
+
T+0, and evaluated against declared pass criteria on close. Test-bench software
|
|
164
|
+
should open one when a test starts and close it when the test ends, rather than
|
|
165
|
+
expecting somebody to drag a time picker afterwards.
|
|
166
|
+
|
|
167
|
+
These live on the **app** host (`https://app.plexus.company`), not the gateway,
|
|
168
|
+
and take the same `x-api-key`.
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
POST /api/runs {name, source_id, started_at, pass_criteria?, tags?}
|
|
172
|
+
PATCH /api/runs/{id} {status: "completed"|"aborted", ended_at}
|
|
173
|
+
GET /api/runs ?source_id=&status=
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`started_at` / `ended_at` are ISO-8601 **with an offset** (`...Z` is fine); a
|
|
177
|
+
naive local timestamp is a 400.
|
|
178
|
+
|
|
179
|
+
In plexus-python (>= 0.11.0) this is `px.start_run()` / `px.end_run()`, or:
|
|
180
|
+
|
|
181
|
+
```python
|
|
182
|
+
with px.run("hotfire-03", pass_criteria=[
|
|
183
|
+
{"metric": "motor.temp_c", "operator": "<", "value": 85},
|
|
184
|
+
]):
|
|
185
|
+
bench.execute()
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
An exception leaving the block closes the run `aborted`; a clean exit closes it
|
|
189
|
+
`completed`.
|
|
190
|
+
|
|
191
|
+
**Pass criteria** are `{metric, operator, value, label?}` with operator one of
|
|
192
|
+
`> >= < <= = !=`. On close, every sample of that metric inside the window is
|
|
193
|
+
checked. A criterion passes only if *all* of them satisfy it — one excursion
|
|
194
|
+
fails it — and a criterion whose metric has no data in the window **fails**
|
|
195
|
+
rather than passing. The verdict lands on the run's `test_result`.
|
|
196
|
+
|
|
197
|
+
Criteria are evaluated against stored telemetry, which trails ingest by a few
|
|
198
|
+
seconds. A run closed the instant its last point is sent may read "no data";
|
|
199
|
+
let the pipeline settle first.
|
|
200
|
+
|
|
159
201
|
## Idioms
|
|
160
202
|
|
|
161
203
|
- **Polling cadences for dashboards**: latest values 5s, charts 10s, fleet health 10s, source list 30s. Use SWR or TanStack Query with `refreshInterval`.
|
|
162
204
|
- **Time ranges**: prefer `last=1h` (relative) over `start`/`end` (absolute) — easier to reason about and less timezone footgun. `start`/`end` are ISO date-times, not epoch ms.
|
|
163
|
-
- **Batching ingest**:
|
|
164
|
-
- **Source IDs are slugs**: `drone-001`, `sat-alpha-3`. Must match `^[a-z0-9][a-z0-
|
|
205
|
+
- **Batching ingest**: the gateway meters **messages, not points** — 500/s per WebSocket connection, 2000/s per source, up to 10,000 points in one message. Buffer up to 64 points or 5 seconds, whichever first. One send per reading at bench rates exceeds the limit and the overflow is *discarded*, reported asynchronously as `RATE_LIMITED` after the send returned. In plexus-python use `px.batch(interval_ms=50)`; `px.send()` alone is one message per call. On 429 / 5xx, exponential backoff with max 3 attempts.
|
|
206
|
+
- **Source IDs are slugs**: `drone-001`, `sat-alpha-3`, `bench.rig-2`. Must match `^[a-z0-9][a-z0-9._-]*$`, up to 256 characters — dots are legal and a single character is legal. A uuid-shaped slug is rejected: every resolver reads uuid-shaped refs as internal ids, so such a source would be unreachable. Stable, lowercase.
|
|
165
207
|
- **Source IDs are not deduplicated.** The gateway writes whatever `source_id` you declare. Two devices declaring the same name merge into one source.
|
|
166
208
|
- **Metric names are opaque and may be long.** Anything that round-trips a name must use the identical string on both sides or the series and its metadata will not join.
|
|
167
209
|
|
|
@@ -180,4 +222,7 @@ When asked to "set up Plexus" in a project, do this:
|
|
|
180
222
|
|
|
181
223
|
Fetch `https://plexus-data-api.fly.dev/openapi.json` for the authoritative HTTP schema (it will not show WebSocket routes), and check a real response before writing parsing code. `scripts/verify_skills.py` in this repo checks these docs against the live spec — run it if something here looks stale.
|
|
182
224
|
|
|
183
|
-
This cheat sheet has drifted before. Corrected 2026-
|
|
225
|
+
This cheat sheet has drifted before. Corrected 2026-09-01 against the shipped
|
|
226
|
+
gateway and app (runs API — which an earlier revision of API.md wrongly said did
|
|
227
|
+
not exist; message-not-point metering and `px.batch()`; the real slug rule,
|
|
228
|
+
which permits dots, single characters and 256 bytes). Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior).
|
|
@@ -14,11 +14,29 @@ Adds a small, well-behaved Plexus ingest client to device-side code. Optimized f
|
|
|
14
14
|
- Targets include: ESP32 / Arduino / RP2040, Raspberry Pi, NVIDIA Jetson, embedded Linux gateways, drone autopilot companion computers, satellite OBC software
|
|
15
15
|
- Languages: C / C++, MicroPython, CPython, Rust, Go (for edge gateways)
|
|
16
16
|
|
|
17
|
-
**If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...)
|
|
17
|
+
**If the target runs CPython (Pi, Jetson, any embedded Linux with room), stop and use the SDK instead** — `pip install plexus-python`, then `Plexus(api_key=..., source_id=...)`. It handles backoff, store-and-forward buffering and the WebSocket transport. Hand-rolling is for targets the SDK can't reach.
|
|
18
|
+
|
|
19
|
+
**`send()` does NOT batch.** Every call is its own message on the wire, and the ceiling below counts messages. Above a few readings a second, use `px.batch()`:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
with px.batch(interval_ms=50) as b: # one message per interval
|
|
23
|
+
while running:
|
|
24
|
+
b.send("imu.accel_x", imu.x) # same signature as px.send()
|
|
25
|
+
b.send("imu.accel_y", imu.y)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Leaving the block flushes what is queued, and readings keep the timestamp they were taken at. Requires plexus-python >= 0.11.0.
|
|
18
29
|
|
|
19
30
|
For **server-side ingestion** (a backend collecting metrics and forwarding), the generic `plexus` skill is fine.
|
|
20
31
|
For **dashboards / UI**, use `plexus-dashboard`.
|
|
21
32
|
|
|
33
|
+
**On a test bench, open a run.** Software driving a test should name its window
|
|
34
|
+
rather than leave someone to find it on a chart later: `POST /api/runs` when the
|
|
35
|
+
test starts, `PATCH /api/runs/{id}` with `status` and `ended_at` when it ends
|
|
36
|
+
(app host, same `x-api-key`), or `with px.run(...)` from plexus-python. Declared
|
|
37
|
+
`pass_criteria` are evaluated against every sample in the window on close. See
|
|
38
|
+
the `plexus` skill for the contract.
|
|
39
|
+
|
|
22
40
|
## The ingest contract
|
|
23
41
|
|
|
24
42
|
```
|
|
@@ -53,7 +71,26 @@ Every firmware integration must do these. Skip any of them and you'll lose data
|
|
|
53
71
|
|
|
54
72
|
### 1. Batch
|
|
55
73
|
|
|
56
|
-
|
|
74
|
+
**The gateway meters messages, not points.** One request carrying 200 points costs
|
|
75
|
+
exactly what one carrying 1 costs, so the shape of your sends — not their volume —
|
|
76
|
+
decides whether you hit a limit:
|
|
77
|
+
|
|
78
|
+
| Limit | Value |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| Telemetry messages per WebSocket connection | 500/s sustained, 2000 burst |
|
|
81
|
+
| Hard ceiling per source (WS and HTTP) | 2000 messages/s |
|
|
82
|
+
| Points per message | 10,000 |
|
|
83
|
+
| Body size | 1 MB (WS) / 5 MB (HTTP) |
|
|
84
|
+
|
|
85
|
+
Eight channels at 100 Hz sent one at a time is 800 messages/s — over the limit,
|
|
86
|
+
and **the overflow is discarded**. Batched every 50 ms it is 20 messages/s.
|
|
87
|
+
|
|
88
|
+
Over the limit the gateway drops the whole message and answers `RATE_LIMITED`,
|
|
89
|
+
which arrives *after* the send returned. Those points cannot be recovered, so
|
|
90
|
+
this is a batching problem, not a retry one.
|
|
91
|
+
|
|
92
|
+
Never POST one point at a time. Buffer until **64 points OR 5 seconds**, whichever
|
|
93
|
+
first, then flush. Constants worth exposing:
|
|
57
94
|
|
|
58
95
|
```c
|
|
59
96
|
#define PLEXUS_BATCH_SIZE 64
|
|
@@ -10,6 +10,7 @@ The HTTP layer is stubbed at `_Session.request`, so no socket is opened.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import time
|
|
13
14
|
|
|
14
15
|
import pytest
|
|
15
16
|
|
|
@@ -72,6 +73,47 @@ def test_started_at_matches_the_shape_the_route_validates():
|
|
|
72
73
|
datetime.fromisoformat(started.replace("Z", "+00:00")) # parses or raises
|
|
73
74
|
|
|
74
75
|
|
|
76
|
+
def test_run_boundaries_are_stamped_on_the_server_clock():
|
|
77
|
+
"""The window must be measured by the same clock as the points inside it.
|
|
78
|
+
|
|
79
|
+
`_normalize_ts_ms` corrects generated timestamps by the offset the gateway
|
|
80
|
+
reports on connect. Stamping run boundaries from the local clock instead
|
|
81
|
+
leaves `ended_at` behind every point taken in the final `offset`
|
|
82
|
+
milliseconds — and those points then fall outside the window their own run
|
|
83
|
+
is evaluated over. Measured on production before the fix: a 300-point run
|
|
84
|
+
on a device 58ms behind the server was judged on 284 of them, and the
|
|
85
|
+
shortfall reads exactly like ingest lag.
|
|
86
|
+
"""
|
|
87
|
+
from datetime import datetime
|
|
88
|
+
|
|
89
|
+
session = _StubSession()
|
|
90
|
+
px = _client(session)
|
|
91
|
+
px._clock_offset_ms = 5_000 # device 5s behind the server
|
|
92
|
+
|
|
93
|
+
px.start_run("run")
|
|
94
|
+
px.end_run({"id": "run_1"})
|
|
95
|
+
|
|
96
|
+
def as_ms(iso: str) -> int:
|
|
97
|
+
return int(datetime.fromisoformat(iso.replace("Z", "+00:00")).timestamp() * 1000)
|
|
98
|
+
|
|
99
|
+
local_ms = int(time.time() * 1000)
|
|
100
|
+
started = as_ms(session.calls[0][2]["started_at"])
|
|
101
|
+
ended = as_ms(session.calls[1][2]["ended_at"])
|
|
102
|
+
|
|
103
|
+
# Both carry the offset, so they bound points that also carry it.
|
|
104
|
+
assert started - local_ms > 4_000
|
|
105
|
+
assert ended - local_ms > 4_000
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_an_explicit_boundary_is_left_alone():
|
|
109
|
+
"""A caller passing its own timestamp owns it — we do not shift it."""
|
|
110
|
+
session = _StubSession()
|
|
111
|
+
px = _client(session)
|
|
112
|
+
px._clock_offset_ms = 5_000
|
|
113
|
+
px.start_run("run", started_at="2026-09-01T10:00:00.000Z")
|
|
114
|
+
assert session.calls[0][2]["started_at"] == "2026-09-01T10:00:00.000Z"
|
|
115
|
+
|
|
116
|
+
|
|
75
117
|
def test_run_defaults_to_the_clients_own_source():
|
|
76
118
|
session = _StubSession()
|
|
77
119
|
px = _client(session)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|