fleetpull 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.
- fleetpull/__init__.py +36 -0
- fleetpull/api/__init__.py +32 -0
- fleetpull/api/auth_ingress.py +216 -0
- fleetpull/api/catalog.py +139 -0
- fleetpull/api/fetch.py +229 -0
- fleetpull/api/identity.py +90 -0
- fleetpull/api/sync.py +735 -0
- fleetpull/cli.py +146 -0
- fleetpull/config/__init__.py +47 -0
- fleetpull/config/base.py +18 -0
- fleetpull/config/example.py +90 -0
- fleetpull/config/geotab.py +78 -0
- fleetpull/config/http.py +32 -0
- fleetpull/config/loading.py +195 -0
- fleetpull/config/logger.py +139 -0
- fleetpull/config/providers.py +520 -0
- fleetpull/config/rate_limit.py +50 -0
- fleetpull/config/resolution.py +156 -0
- fleetpull/config/retry.py +69 -0
- fleetpull/config/root.py +157 -0
- fleetpull/config/sections.py +146 -0
- fleetpull/endpoints/__init__.py +24 -0
- fleetpull/endpoints/geotab/__init__.py +12 -0
- fleetpull/endpoints/geotab/_requests.py +427 -0
- fleetpull/endpoints/geotab/annotation_logs.py +73 -0
- fleetpull/endpoints/geotab/audits.py +70 -0
- fleetpull/endpoints/geotab/devices.py +79 -0
- fleetpull/endpoints/geotab/driver_changes.py +73 -0
- fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
- fleetpull/endpoints/geotab/dvir_logs.py +76 -0
- fleetpull/endpoints/geotab/exception_events.py +119 -0
- fleetpull/endpoints/geotab/fault_data.py +72 -0
- fleetpull/endpoints/geotab/fill_ups.py +79 -0
- fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
- fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
- fleetpull/endpoints/geotab/log_records.py +74 -0
- fleetpull/endpoints/geotab/media_files.py +74 -0
- fleetpull/endpoints/geotab/shipment_logs.py +71 -0
- fleetpull/endpoints/geotab/status_data.py +71 -0
- fleetpull/endpoints/geotab/text_messages.py +77 -0
- fleetpull/endpoints/geotab/trips.py +102 -0
- fleetpull/endpoints/geotab/users.py +81 -0
- fleetpull/endpoints/motive/__init__.py +12 -0
- fleetpull/endpoints/motive/_spec_builders.py +94 -0
- fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
- fleetpull/endpoints/motive/driving_periods.py +85 -0
- fleetpull/endpoints/motive/groups.py +61 -0
- fleetpull/endpoints/motive/idle_events.py +94 -0
- fleetpull/endpoints/motive/users.py +64 -0
- fleetpull/endpoints/motive/vehicle_locations.py +170 -0
- fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
- fleetpull/endpoints/motive/vehicles.py +106 -0
- fleetpull/endpoints/registry.py +280 -0
- fleetpull/endpoints/samsara/__init__.py +12 -0
- fleetpull/endpoints/samsara/_spec_builders.py +202 -0
- fleetpull/endpoints/samsara/addresses.py +77 -0
- fleetpull/endpoints/samsara/asset_locations.py +217 -0
- fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
- fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
- fleetpull/endpoints/samsara/drivers.py +169 -0
- fleetpull/endpoints/samsara/engine_states.py +114 -0
- fleetpull/endpoints/samsara/gps_readings.py +113 -0
- fleetpull/endpoints/samsara/idling_events.py +195 -0
- fleetpull/endpoints/samsara/odometer_readings.py +116 -0
- fleetpull/endpoints/samsara/trips.py +217 -0
- fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
- fleetpull/endpoints/samsara/vehicles.py +121 -0
- fleetpull/endpoints/shared/__init__.py +57 -0
- fleetpull/endpoints/shared/base.py +529 -0
- fleetpull/endpoints/shared/request_shape.py +227 -0
- fleetpull/endpoints/shared/resume.py +74 -0
- fleetpull/endpoints/shared/spec_builders.py +59 -0
- fleetpull/endpoints/shared/sync_mode.py +159 -0
- fleetpull/endpoints/shared/url_paths.py +149 -0
- fleetpull/exceptions.py +320 -0
- fleetpull/incremental/__init__.py +27 -0
- fleetpull/incremental/cursor.py +66 -0
- fleetpull/incremental/resolution.py +152 -0
- fleetpull/incremental/seed.py +53 -0
- fleetpull/incremental/window.py +94 -0
- fleetpull/logger/__init__.py +5 -0
- fleetpull/logger/setup.py +145 -0
- fleetpull/model_contract/__init__.py +6 -0
- fleetpull/model_contract/coercions.py +33 -0
- fleetpull/model_contract/response.py +45 -0
- fleetpull/models/__init__.py +7 -0
- fleetpull/models/geotab/__init__.py +166 -0
- fleetpull/models/geotab/annotation_log.py +109 -0
- fleetpull/models/geotab/audit.py +56 -0
- fleetpull/models/geotab/device.py +217 -0
- fleetpull/models/geotab/driver_change.py +102 -0
- fleetpull/models/geotab/duty_status_log.py +200 -0
- fleetpull/models/geotab/dvir_log.py +184 -0
- fleetpull/models/geotab/exception_event.py +135 -0
- fleetpull/models/geotab/fault_data.py +168 -0
- fleetpull/models/geotab/fill_up.py +189 -0
- fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
- fleetpull/models/geotab/fuel_tax_detail.py +150 -0
- fleetpull/models/geotab/log_record.py +68 -0
- fleetpull/models/geotab/media_file.py +133 -0
- fleetpull/models/geotab/shared.py +221 -0
- fleetpull/models/geotab/shipment_log.py +105 -0
- fleetpull/models/geotab/status_data.py +108 -0
- fleetpull/models/geotab/text_message.py +125 -0
- fleetpull/models/geotab/trip.py +162 -0
- fleetpull/models/geotab/user.py +187 -0
- fleetpull/models/motive/__init__.py +43 -0
- fleetpull/models/motive/driver_idle_rollup.py +109 -0
- fleetpull/models/motive/driving_period.py +82 -0
- fleetpull/models/motive/group.py +49 -0
- fleetpull/models/motive/idle_event.py +77 -0
- fleetpull/models/motive/shared.py +192 -0
- fleetpull/models/motive/user.py +217 -0
- fleetpull/models/motive/vehicle.py +162 -0
- fleetpull/models/motive/vehicle_location.py +127 -0
- fleetpull/models/motive/vehicle_utilization.py +128 -0
- fleetpull/models/samsara/__init__.py +108 -0
- fleetpull/models/samsara/address.py +149 -0
- fleetpull/models/samsara/asset_location.py +131 -0
- fleetpull/models/samsara/driver.py +232 -0
- fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
- fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
- fleetpull/models/samsara/engine_state.py +92 -0
- fleetpull/models/samsara/gps_reading.py +146 -0
- fleetpull/models/samsara/idling_event.py +174 -0
- fleetpull/models/samsara/odometer_reading.py +90 -0
- fleetpull/models/samsara/trip.py +199 -0
- fleetpull/models/samsara/vehicle.py +167 -0
- fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
- fleetpull/network/__init__.py +9 -0
- fleetpull/network/auth/__init__.py +15 -0
- fleetpull/network/auth/authenticate.py +271 -0
- fleetpull/network/auth/manager.py +223 -0
- fleetpull/network/auth/models.py +57 -0
- fleetpull/network/auth/strategies.py +169 -0
- fleetpull/network/classifiers/__init__.py +11 -0
- fleetpull/network/classifiers/geotab.py +145 -0
- fleetpull/network/classifiers/motive.py +79 -0
- fleetpull/network/classifiers/samsara.py +80 -0
- fleetpull/network/client/__init__.py +25 -0
- fleetpull/network/client/page.py +32 -0
- fleetpull/network/client/profile.py +27 -0
- fleetpull/network/client/registry.py +103 -0
- fleetpull/network/client/registry_base.py +150 -0
- fleetpull/network/client/runtime.py +44 -0
- fleetpull/network/client/transport.py +381 -0
- fleetpull/network/contract/__init__.py +50 -0
- fleetpull/network/contract/auth.py +48 -0
- fleetpull/network/contract/classifier.py +189 -0
- fleetpull/network/contract/envelope_fetcher.py +33 -0
- fleetpull/network/contract/envelopes.py +189 -0
- fleetpull/network/contract/outcome.py +43 -0
- fleetpull/network/contract/page_decoder.py +101 -0
- fleetpull/network/contract/request.py +97 -0
- fleetpull/network/decoders/__init__.py +40 -0
- fleetpull/network/decoders/_window_stamp.py +78 -0
- fleetpull/network/decoders/geotab.py +309 -0
- fleetpull/network/decoders/motive.py +204 -0
- fleetpull/network/decoders/motive_reports.py +114 -0
- fleetpull/network/decoders/samsara.py +343 -0
- fleetpull/network/decoders/samsara_reports.py +119 -0
- fleetpull/network/decoders/single_page.py +55 -0
- fleetpull/network/limits/__init__.py +13 -0
- fleetpull/network/limits/bucket_math.py +58 -0
- fleetpull/network/limits/limiter.py +153 -0
- fleetpull/network/limits/registry.py +96 -0
- fleetpull/network/posture/__init__.py +6 -0
- fleetpull/network/posture/client_options.py +96 -0
- fleetpull/network/retry/__init__.py +13 -0
- fleetpull/network/retry/decision.py +155 -0
- fleetpull/network/tls/__init__.py +5 -0
- fleetpull/network/tls/truststore_context.py +35 -0
- fleetpull/orchestrator/__init__.py +30 -0
- fleetpull/orchestrator/backfill.py +127 -0
- fleetpull/orchestrator/batch.py +155 -0
- fleetpull/orchestrator/bisection.py +271 -0
- fleetpull/orchestrator/drivers.py +350 -0
- fleetpull/orchestrator/entry.py +347 -0
- fleetpull/orchestrator/executors.py +128 -0
- fleetpull/orchestrator/fanout.py +163 -0
- fleetpull/orchestrator/feed_drive.py +266 -0
- fleetpull/orchestrator/metadata_projection.py +195 -0
- fleetpull/orchestrator/outcome.py +53 -0
- fleetpull/orchestrator/recording.py +85 -0
- fleetpull/orchestrator/resume.py +143 -0
- fleetpull/orchestrator/roster_harvest.py +108 -0
- fleetpull/orchestrator/roster_refresh.py +363 -0
- fleetpull/orchestrator/runner.py +262 -0
- fleetpull/orchestrator/shape_resolution.py +221 -0
- fleetpull/orchestrator/spine.py +166 -0
- fleetpull/orchestrator/streaming.py +153 -0
- fleetpull/orchestrator/unit_loop.py +273 -0
- fleetpull/orchestrator/watermark_drive.py +455 -0
- fleetpull/paths/__init__.py +13 -0
- fleetpull/paths/datasets.py +38 -0
- fleetpull/paths/partitions.py +41 -0
- fleetpull/paths/resolution.py +98 -0
- fleetpull/polars_typing/__init__.py +20 -0
- fleetpull/py.typed +0 -0
- fleetpull/records/__init__.py +19 -0
- fleetpull/records/convert.py +53 -0
- fleetpull/records/dataframe.py +63 -0
- fleetpull/records/event_time.py +70 -0
- fleetpull/records/fields.py +190 -0
- fleetpull/records/flatten.py +54 -0
- fleetpull/records/roster_members.py +74 -0
- fleetpull/records/schema.py +73 -0
- fleetpull/records/validation.py +62 -0
- fleetpull/resources/__init__.py +10 -0
- fleetpull/resources/config.example.yaml +222 -0
- fleetpull/roster/__init__.py +16 -0
- fleetpull/roster/definition.py +46 -0
- fleetpull/roster/key.py +40 -0
- fleetpull/roster/registry.py +93 -0
- fleetpull/state/__init__.py +36 -0
- fleetpull/state/cursors.py +417 -0
- fleetpull/state/database.py +463 -0
- fleetpull/state/migrations.py +430 -0
- fleetpull/state/reconcile.py +127 -0
- fleetpull/state/rosters.py +159 -0
- fleetpull/state/run_ledger.py +572 -0
- fleetpull/state/work_units.py +648 -0
- fleetpull/storage/__init__.py +30 -0
- fleetpull/storage/append.py +187 -0
- fleetpull/storage/atomic.py +161 -0
- fleetpull/storage/files.py +176 -0
- fleetpull/storage/frames.py +98 -0
- fleetpull/storage/metadata.py +161 -0
- fleetpull/storage/partitioned.py +205 -0
- fleetpull/storage/pruning.py +169 -0
- fleetpull/storage/read.py +35 -0
- fleetpull/storage/result.py +34 -0
- fleetpull/storage/single_file.py +130 -0
- fleetpull/storage/splitting.py +88 -0
- fleetpull/storage/staging.py +178 -0
- fleetpull/storage/writers.py +145 -0
- fleetpull/timing/__init__.py +21 -0
- fleetpull/timing/canon.py +92 -0
- fleetpull/timing/clock.py +192 -0
- fleetpull/timing/codec.py +122 -0
- fleetpull/timing/sleeper.py +65 -0
- fleetpull/vocabulary/__init__.py +16 -0
- fleetpull/vocabulary/json_types.py +20 -0
- fleetpull/vocabulary/provider.py +29 -0
- fleetpull/vocabulary/quota_scope.py +47 -0
- fleetpull/vocabulary/response_category.py +32 -0
- fleetpull-0.1.0.dist-info/METADATA +248 -0
- fleetpull-0.1.0.dist-info/RECORD +252 -0
- fleetpull-0.1.0.dist-info/WHEEL +5 -0
- fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
- fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
- fleetpull-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/idling_events.py
|
|
2
|
+
"""The Samsara idling_events binding: the fleet-wide windowed cursor walk --
|
|
3
|
+
the first windowed+cursor pairing, composed entirely from existing parts.
|
|
4
|
+
|
|
5
|
+
``GET /idling/events`` is a modern-envelope surface (``data`` +
|
|
6
|
+
``pagination {endCursor, hasNextPage}``, terminal on ``hasNextPage:
|
|
7
|
+
false`` beside an empty-string ``endCursor``; the cursor walk proven
|
|
8
|
+
live 2026-07-20: 11 pages at limit=200, 2,200/2,200 unique). Events are
|
|
9
|
+
fleet-wide with per-record asset attribution, so there is NO fan-out --
|
|
10
|
+
the default ``SingleFetch`` shape, declared by declaring nothing (the
|
|
11
|
+
Motive driving_periods template on the Samsara cursor decoder).
|
|
12
|
+
|
|
13
|
+
The windowed leaf builder composes with the existing
|
|
14
|
+
``SamsaraCursorPageDecoder`` because pagination parameters persist by
|
|
15
|
+
construction: the decoder's ``first_request`` merges ``limit`` onto the
|
|
16
|
+
builder's spec, and its ``after`` advance merges onto the SENT spec, so
|
|
17
|
+
the builder's ``startTime``/``endTime`` ride every page of the walk --
|
|
18
|
+
the mechanism proven live on the drivers sweep, now carrying a window
|
|
19
|
+
instead of a status.
|
|
20
|
+
|
|
21
|
+
The per-endpoint ``limit`` maximum is 200, NOT the 512 of
|
|
22
|
+
vehicles/drivers: limit=512 returns a loud JSON 400 (``"limit must be
|
|
23
|
+
lesser or equal than 200 but got value 512"``) -- the first captured
|
|
24
|
+
instance of Samsara's per-endpoint limit tiers; never assume a
|
|
25
|
+
sibling's limit (DESIGN §8, captured 2026-07-20).
|
|
26
|
+
|
|
27
|
+
Retrieval is START-anchored on UTC, proven by a discriminating pair on
|
|
28
|
+
a 6.5-hour event: a 60-second window strictly inside its span does NOT
|
|
29
|
+
return it, while a 60-second window straddling only its start DOES
|
|
30
|
+
(the fourth distinct anchoring datum across providers -- notably NOT
|
|
31
|
+
the company-local overlap behavior of Motive's idle_events sibling;
|
|
32
|
+
never assume the rule, per endpoint, ever). Consequence:
|
|
33
|
+
``event_time_column='start_time'``, the retrieval anchor and the
|
|
34
|
+
routing anchor coincide natively, no wire pad exists, and the runner's
|
|
35
|
+
post-fetch window filter is pure hygiene (the driving_periods
|
|
36
|
+
situation).
|
|
37
|
+
|
|
38
|
+
Records carry NO end key -- the interval is start plus
|
|
39
|
+
``durationMilliseconds``; events were only ever observed complete
|
|
40
|
+
(in-progress idles appear to materialize on completion), and the
|
|
41
|
+
watermark lookback absorbs late materialization (accepted residual).
|
|
42
|
+
|
|
43
|
+
The provider enforces a loud sub-3-months range cap -- 91 days
|
|
44
|
+
accepted; 180 days returns JSON 400 ``"Total duration must be less
|
|
45
|
+
than 3 months."``. No builder guard exists for it (the Motive
|
|
46
|
+
driving_periods stance): default 7-day backfill chunks sit far inside,
|
|
47
|
+
and a ``backfill_chunk_days`` raised past the cap fails loudly on the
|
|
48
|
+
first request rather than losing data silently -- the fix is a smaller
|
|
49
|
+
chunk width.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from collections.abc import Mapping
|
|
53
|
+
from dataclasses import dataclass
|
|
54
|
+
from datetime import timedelta
|
|
55
|
+
from typing import Final
|
|
56
|
+
|
|
57
|
+
from fleetpull.config import SamsaraConfig
|
|
58
|
+
from fleetpull.endpoints.shared import (
|
|
59
|
+
EndpointDefinition,
|
|
60
|
+
ResumeValue,
|
|
61
|
+
StorageKind,
|
|
62
|
+
WatermarkMode,
|
|
63
|
+
require_date_window,
|
|
64
|
+
)
|
|
65
|
+
from fleetpull.models.samsara import IdlingEvent
|
|
66
|
+
from fleetpull.network.contract import HttpMethod, RequestSpec
|
|
67
|
+
from fleetpull.network.decoders import SamsaraCursorPageDecoder
|
|
68
|
+
from fleetpull.timing import to_iso8601
|
|
69
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
70
|
+
|
|
71
|
+
__all__: list[str] = [
|
|
72
|
+
'SamsaraIdlingEventsSpecBuilder',
|
|
73
|
+
'build_endpoint',
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
_IDLING_EVENTS_PATH: Final[str] = '/idling/events'
|
|
77
|
+
_RECORDS_KEY: Final[str] = 'data'
|
|
78
|
+
|
|
79
|
+
# The per-page record count. 200 is THIS endpoint's probed maximum --
|
|
80
|
+
# NOT the 512 of vehicles/drivers: limit=512 earns a loud JSON 400
|
|
81
|
+
# naming the 200 cap (captured 2026-07-20), the first captured instance
|
|
82
|
+
# of Samsara's per-endpoint limit tiers. Never assume a sibling's limit.
|
|
83
|
+
_RESULTS_LIMIT: Final[int] = 200
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True)
|
|
87
|
+
class SamsaraIdlingEventsSpecBuilder:
|
|
88
|
+
"""Build the fleet-wide, date-windowed first request for idling events.
|
|
89
|
+
|
|
90
|
+
The ``SpecBuilder`` for the idling_events single chain: a fixed
|
|
91
|
+
``GET base_url + path`` carrying the resume window as RFC3339
|
|
92
|
+
``startTime``/``endTime`` (the timing codec's ``to_iso8601``). The
|
|
93
|
+
decoder owns pagination: its ``first_request`` merges ``limit``
|
|
94
|
+
onto this spec and its ``after`` advance merges onto the sent spec,
|
|
95
|
+
so the window parameters persist across the whole cursor walk.
|
|
96
|
+
|
|
97
|
+
The canonical half-open ``[start, end)`` window maps to the wire as
|
|
98
|
+
``startTime = start`` and ``endTime = end``. Retrieval is
|
|
99
|
+
START-anchored on UTC (module docstring), so a window's records are
|
|
100
|
+
exactly those starting inside it; any boundary-instant reading of
|
|
101
|
+
``endTime`` the wire might take is absorbed by the runner's
|
|
102
|
+
post-fetch window filter, which keeps only events whose start lies
|
|
103
|
+
in ``[start, end)`` -- pure hygiene, never load-bearing.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
base_url: Root of the Samsara API, trailing-slash-normalized by
|
|
107
|
+
the provider config so the leading-slash path joins
|
|
108
|
+
directly.
|
|
109
|
+
path: The endpoint's leading-slash request path
|
|
110
|
+
(``'/idling/events'``).
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
base_url: str
|
|
114
|
+
path: str
|
|
115
|
+
|
|
116
|
+
def build_spec(
|
|
117
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
118
|
+
) -> RequestSpec:
|
|
119
|
+
"""Build the fleet-wide, date-windowed GET.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
resume: The run's resume window. Must be a ``DateWindow`` --
|
|
123
|
+
a watermark endpoint always resumes from one; any other
|
|
124
|
+
value is a wiring bug.
|
|
125
|
+
member_values: Accepted to satisfy the protocol; unused -- a
|
|
126
|
+
fleet-wide single chain binds no member.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A credential-less ``GET`` for ``base_url + path`` carrying
|
|
130
|
+
the window's bounds as RFC3339 ``startTime``/``endTime``.
|
|
131
|
+
Auth headers are layered on by the client's
|
|
132
|
+
``ProviderProfile``; pagination parameters are injected by
|
|
133
|
+
the page decoder's ``first_request``.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
137
|
+
ValueError: A window bound is not canonical UTC.
|
|
138
|
+
|
|
139
|
+
Side Effects:
|
|
140
|
+
None.
|
|
141
|
+
"""
|
|
142
|
+
resume_window = require_date_window(resume, type(self).__name__)
|
|
143
|
+
params = {
|
|
144
|
+
'startTime': to_iso8601(resume_window.start),
|
|
145
|
+
'endTime': to_iso8601(resume_window.end),
|
|
146
|
+
}
|
|
147
|
+
return RequestSpec(
|
|
148
|
+
method=HttpMethod.GET,
|
|
149
|
+
url=f'{self.base_url}{self.path}',
|
|
150
|
+
params=params,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[IdlingEvent]:
|
|
155
|
+
"""Build the Samsara idling_events watermark binding.
|
|
156
|
+
|
|
157
|
+
Fleet-wide idling events fetched incrementally: the run resumes
|
|
158
|
+
from a ``DateWindow`` (watermark with the provider's late-arrival
|
|
159
|
+
lookback from config -- which also absorbs idles materializing on
|
|
160
|
+
completion), the fetched events are written to ``date=YYYY-MM-DD``
|
|
161
|
+
partitions on ``start_time``, and each refetched partition is
|
|
162
|
+
replaced. Records arrive as a top-level list under ``data``, walked
|
|
163
|
+
by explicit cursor pages (``limit`` on page one, ``after`` merged
|
|
164
|
+
thereafter, the window parameters persisting throughout), terminal
|
|
165
|
+
on ``hasNextPage: false``. No request shape is declared -- the
|
|
166
|
+
endpoint is a fleet-wide ``SingleFetch``, the default.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
config: The validated Samsara configuration; supplies the base
|
|
170
|
+
URL the spec-builder joins to the idling-events path and
|
|
171
|
+
the lookback and cutoff the watermark mode carries.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The frozen idling_events ``EndpointDefinition``. Construction
|
|
175
|
+
validates the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
|
|
176
|
+
``event_time_column`` triple against the response model.
|
|
177
|
+
"""
|
|
178
|
+
return EndpointDefinition(
|
|
179
|
+
provider=Provider.SAMSARA,
|
|
180
|
+
name='idling_events',
|
|
181
|
+
spec_builder=SamsaraIdlingEventsSpecBuilder(
|
|
182
|
+
base_url=config.base_url, path=_IDLING_EVENTS_PATH
|
|
183
|
+
),
|
|
184
|
+
page_decoder=SamsaraCursorPageDecoder(
|
|
185
|
+
records_key=_RECORDS_KEY, results_limit=_RESULTS_LIMIT
|
|
186
|
+
),
|
|
187
|
+
response_model=IdlingEvent,
|
|
188
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
189
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
190
|
+
sync_mode=WatermarkMode(
|
|
191
|
+
lookback=timedelta(days=config.lookback_days),
|
|
192
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
193
|
+
),
|
|
194
|
+
event_time_column='start_time',
|
|
195
|
+
)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/odometer_readings.py
|
|
2
|
+
"""The Samsara odometer_readings binding: the vehicle-stats windowed
|
|
3
|
+
cursor walk for ``types=obdOdometerMeters`` -- one of the three
|
|
4
|
+
endpoints the legacy ``/fleet/vehicles/stats/history`` surface splits
|
|
5
|
+
into (engine_states, gps_readings, odometer_readings; the three stat
|
|
6
|
+
types carry disjoint schemas, so each is its own entity and dataset --
|
|
7
|
+
DESIGN §8).
|
|
8
|
+
|
|
9
|
+
``GET /fleet/vehicles/stats/history`` is a modern-envelope surface
|
|
10
|
+
(``data`` + ``pagination {endCursor, hasNextPage}``), but its cursor
|
|
11
|
+
walks the VEHICLE axis within the fixed window: three consecutive live
|
|
12
|
+
pages showed zero vehicle-id overlap (captured 2026-07-20). Each
|
|
13
|
+
vehicle record nests one reading series under the requested type's key,
|
|
14
|
+
so the binding pairs the shared windowed builder (which bakes the FIXED
|
|
15
|
+
``types=obdOdometerMeters`` selector into every request -- the
|
|
16
|
+
``types`` vocabulary is API-enforced on input, a loud 400 on any
|
|
17
|
+
unknown value) with ``SamsaraVehicleSeriesPageDecoder``, which unnests
|
|
18
|
+
each vehicle's series into flat per-reading records and delegates
|
|
19
|
+
pagination to the inner cursor decoder untouched.
|
|
20
|
+
|
|
21
|
+
Only carrier vehicles are returned per requested type (24-hour walk:
|
|
22
|
+
135 vehicles, 135 with data -- no empty-array padding observed), and
|
|
23
|
+
the endpoint is fleet-wide with per-record vehicle attribution
|
|
24
|
+
synthesized by the decoder, so there is NO fan-out -- the default
|
|
25
|
+
``SingleFetch`` shape, declared by declaring nothing, and no roster is
|
|
26
|
+
sourced or consumed.
|
|
27
|
+
|
|
28
|
+
The per-endpoint ``limit`` maximum is 512, probed directly on THIS
|
|
29
|
+
surface: limit=512 returned HTTP 200 and limit=513 a loud HTTP 400 --
|
|
30
|
+
the vehicles/drivers tier, NOT idling's 200 (the per-endpoint
|
|
31
|
+
limit-tier rule, honored by probing rather than assuming).
|
|
32
|
+
|
|
33
|
+
Retrieval is READING-TIME anchored on the half-open ``[startTime,
|
|
34
|
+
endTime)`` window, probe-proven: a 12:00-13:00Z window returned
|
|
35
|
+
readings spanning exactly 12:00:03.062Z..12:59:56.881Z. Consequence:
|
|
36
|
+
``event_time_column='time'``, the retrieval anchor and the routing
|
|
37
|
+
anchor coincide natively, no wire pad exists, and the runner's
|
|
38
|
+
post-fetch window filter is pure hygiene.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from datetime import timedelta
|
|
42
|
+
from typing import Final
|
|
43
|
+
|
|
44
|
+
from fleetpull.config import SamsaraConfig
|
|
45
|
+
from fleetpull.endpoints.samsara._spec_builders import (
|
|
46
|
+
RECORDS_KEY,
|
|
47
|
+
RESULTS_LIMIT,
|
|
48
|
+
STATS_HISTORY_PATH,
|
|
49
|
+
SamsaraVehicleStatsSpecBuilder,
|
|
50
|
+
)
|
|
51
|
+
from fleetpull.endpoints.shared import (
|
|
52
|
+
EndpointDefinition,
|
|
53
|
+
StorageKind,
|
|
54
|
+
WatermarkMode,
|
|
55
|
+
)
|
|
56
|
+
from fleetpull.models.samsara import OdometerReading
|
|
57
|
+
from fleetpull.network.decoders import SamsaraVehicleSeriesPageDecoder
|
|
58
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
59
|
+
|
|
60
|
+
__all__: list[str] = ['build_endpoint']
|
|
61
|
+
|
|
62
|
+
# The requested stat type -- the verbatim `types` wire value AND the
|
|
63
|
+
# per-vehicle series key the decoder unnests (one name on the wire for
|
|
64
|
+
# both roles, captured 2026-07-20).
|
|
65
|
+
_STAT_TYPE: Final[str] = 'obdOdometerMeters'
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[OdometerReading]:
|
|
69
|
+
"""Build the Samsara odometer_readings watermark binding.
|
|
70
|
+
|
|
71
|
+
Fleet-wide OBD odometer readings fetched incrementally: the run
|
|
72
|
+
resumes from a ``DateWindow`` (watermark with the provider's
|
|
73
|
+
late-arrival lookback from config), the fetched readings are
|
|
74
|
+
written to ``date=YYYY-MM-DD`` partitions on ``time``, and each
|
|
75
|
+
refetched partition is replaced. Vehicle records arrive as a
|
|
76
|
+
top-level list under ``data``, walked by explicit cursor pages
|
|
77
|
+
along the VEHICLE axis (``limit`` on page one, ``after`` merged
|
|
78
|
+
thereafter, the window and ``types`` parameters persisting
|
|
79
|
+
throughout), and the decoder unnests each vehicle's
|
|
80
|
+
``obdOdometerMeters`` series into one flat record per reading. No
|
|
81
|
+
request shape is declared -- the endpoint is a fleet-wide
|
|
82
|
+
``SingleFetch``, the default.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
config: The validated Samsara configuration; supplies the base
|
|
86
|
+
URL the spec-builder joins to the stats-history path and
|
|
87
|
+
the lookback and cutoff the watermark mode carries.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The frozen odometer_readings ``EndpointDefinition``.
|
|
91
|
+
Construction validates the ``WatermarkMode`` /
|
|
92
|
+
``DATE_PARTITIONED`` / ``event_time_column`` triple against the
|
|
93
|
+
response model.
|
|
94
|
+
"""
|
|
95
|
+
return EndpointDefinition(
|
|
96
|
+
provider=Provider.SAMSARA,
|
|
97
|
+
name='odometer_readings',
|
|
98
|
+
spec_builder=SamsaraVehicleStatsSpecBuilder(
|
|
99
|
+
base_url=config.base_url,
|
|
100
|
+
path=STATS_HISTORY_PATH,
|
|
101
|
+
stat_type=_STAT_TYPE,
|
|
102
|
+
),
|
|
103
|
+
page_decoder=SamsaraVehicleSeriesPageDecoder(
|
|
104
|
+
records_key=RECORDS_KEY,
|
|
105
|
+
results_limit=RESULTS_LIMIT,
|
|
106
|
+
series_key=_STAT_TYPE,
|
|
107
|
+
),
|
|
108
|
+
response_model=OdometerReading,
|
|
109
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
110
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
111
|
+
sync_mode=WatermarkMode(
|
|
112
|
+
lookback=timedelta(days=config.lookback_days),
|
|
113
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
114
|
+
),
|
|
115
|
+
event_time_column='time',
|
|
116
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/trips.py
|
|
2
|
+
"""The Samsara trips binding: the per-vehicle windowed fan-out -- the
|
|
3
|
+
roster machinery's first cross-provider consumer.
|
|
4
|
+
|
|
5
|
+
The trips surface is the LEGACY v1 API only: ``GET /v1/fleet/trips``
|
|
6
|
+
(the modern candidates 404 -- ``/fleet/trips``, ``/beta/fleet/trips``,
|
|
7
|
+
``/preview/fleet/trips``; captured 2026-07-20). ``vehicleId`` is
|
|
8
|
+
REQUIRED -- omitting it is a loud HTTP 400 text/plain rpc-error, so the
|
|
9
|
+
endpoint is structurally per-vehicle and the binding declares
|
|
10
|
+
``request_shape=RosterFanOut`` over the Samsara ``vehicle_ids`` roster
|
|
11
|
+
(declared beside its feeder in ``endpoints/samsara/vehicles.py``; this
|
|
12
|
+
module knows only the ``RosterKey``, never the feeder). The fan-out
|
|
13
|
+
member merges as a QUERY parameter -- the drivers-leaf precedent, not
|
|
14
|
+
the Motive path-template one.
|
|
15
|
+
|
|
16
|
+
The envelope is ``{"trips": [...]}`` with no pagination of any kind:
|
|
17
|
+
one response per (vehicle, window), so the shared ``SinglePageDecoder``
|
|
18
|
+
fits (the GeoTab exception_events pairing precedent). Retrieval is
|
|
19
|
+
OVERLAP-anchored, re-verified per-type 2026-07-20 (a 60-second window
|
|
20
|
+
strictly inside a trip's span returned that trip; start- and
|
|
21
|
+
end-anchoring falsified -- DESIGN §4's historical record for this exact
|
|
22
|
+
endpoint, confirmed live). Per §4: overlap retrieval supersets
|
|
23
|
+
start-anchored ownership, so ``event_time_column='start_time'``, the
|
|
24
|
+
runner's post-fetch window filter assigns each trip to the single chunk
|
|
25
|
+
owning its start, and no wire pad is needed (the exception_events
|
|
26
|
+
reasoning verbatim).
|
|
27
|
+
|
|
28
|
+
The provider enforces a LOUD, exactly-90-day range cap -- HTTP 400
|
|
29
|
+
text/plain ``rpc error: ... requested time range cannot exceed 90
|
|
30
|
+
days``; a 90-day window succeeded. No builder guard exists for it (the
|
|
31
|
+
Motive driving_periods stance): default 7-day backfill chunks sit far
|
|
32
|
+
inside, and a ``backfill_chunk_days`` raised past 90 fails loudly on
|
|
33
|
+
the first request rather than losing data silently -- the fix is a
|
|
34
|
+
smaller chunk width.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from collections.abc import Mapping
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime, timedelta
|
|
40
|
+
from typing import Final
|
|
41
|
+
|
|
42
|
+
from fleetpull.config import SamsaraConfig
|
|
43
|
+
from fleetpull.endpoints.shared import (
|
|
44
|
+
EndpointDefinition,
|
|
45
|
+
ResumeValue,
|
|
46
|
+
RosterFanOut,
|
|
47
|
+
StorageKind,
|
|
48
|
+
WatermarkMode,
|
|
49
|
+
require_date_window,
|
|
50
|
+
)
|
|
51
|
+
from fleetpull.models.samsara import Trip
|
|
52
|
+
from fleetpull.network.contract import HttpMethod, RequestSpec
|
|
53
|
+
from fleetpull.network.decoders import SinglePageDecoder
|
|
54
|
+
from fleetpull.roster import RosterKey
|
|
55
|
+
from fleetpull.timing import require_utc
|
|
56
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
57
|
+
|
|
58
|
+
__all__: list[str] = [
|
|
59
|
+
'SamsaraTripsSpecBuilder',
|
|
60
|
+
'build_endpoint',
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
_TRIPS_PATH: Final[str] = '/v1/fleet/trips'
|
|
64
|
+
_RECORDS_KEY: Final[str] = 'trips'
|
|
65
|
+
|
|
66
|
+
# The integer-arithmetic epoch origin: float `.timestamp()` math can land
|
|
67
|
+
# a millisecond-precision bound one ms low (float rounding + int()
|
|
68
|
+
# truncation), so the conversion below is exact integer division --
|
|
69
|
+
# symmetric with the model's no-float-epoch-math decode stance.
|
|
70
|
+
_UNIX_EPOCH: Final[datetime] = datetime(1970, 1, 1, tzinfo=UTC)
|
|
71
|
+
_ONE_MILLISECOND: Final[timedelta] = timedelta(milliseconds=1)
|
|
72
|
+
|
|
73
|
+
# The fan-out member key IS the wire query parameter, verbatim: the spec
|
|
74
|
+
# builder merges member_values into params unchanged, so declaring the
|
|
75
|
+
# exact wire token here leaves no translation seam to drift (the drivers
|
|
76
|
+
# sweep-param stance). The RosterFanOut declaration below carries it.
|
|
77
|
+
_VEHICLE_ID_PARAM: Final[str] = 'vehicleId'
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _to_epoch_milliseconds(bound: datetime) -> int:
|
|
81
|
+
"""Render one window bound as the epoch-millisecond int the wire takes.
|
|
82
|
+
|
|
83
|
+
The UTC guard is the codec-boundary discipline ``DateWindow`` defers
|
|
84
|
+
to its serialization point (DESIGN §4): a naive or non-UTC bound
|
|
85
|
+
reaching a request is a missed ingress, failed loudly.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
bound: A window bound; must be canonical UTC.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Milliseconds since the Unix epoch.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
ValueError: ``bound`` is naive or not canonical UTC.
|
|
95
|
+
|
|
96
|
+
Side Effects:
|
|
97
|
+
None.
|
|
98
|
+
"""
|
|
99
|
+
return (require_utc(bound) - _UNIX_EPOCH) // _ONE_MILLISECOND
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class SamsaraTripsSpecBuilder:
|
|
104
|
+
"""Build the per-vehicle, date-windowed first request for trips.
|
|
105
|
+
|
|
106
|
+
The ``SpecBuilder`` for the trips ``RosterFanOut``: a fixed
|
|
107
|
+
``GET base_url + path`` carrying the chain's member binding as query
|
|
108
|
+
parameters, verbatim (``{'vehicleId': <member>}`` -- the drivers-leaf
|
|
109
|
+
precedent), plus the resume window as ``startMs``/``endMs`` epoch
|
|
110
|
+
milliseconds. The endpoint is not paginated, so this first request
|
|
111
|
+
is the chain's only request; the page decoder returns no successor.
|
|
112
|
+
|
|
113
|
+
The canonical half-open ``[start, end)`` window maps to the wire as
|
|
114
|
+
``startMs = start`` and ``endMs = end`` in epoch milliseconds. At
|
|
115
|
+
the millisecond boundary the wire's inclusive reading of ``endMs``
|
|
116
|
+
mismatches the half-open convention (a trip touching the exact end
|
|
117
|
+
instant may be returned) -- deliberately unadjusted: retrieval is
|
|
118
|
+
overlap-anchored and supersets start-anchored ownership anyway, and
|
|
119
|
+
the runner's post-fetch window filter keeps only trips whose start
|
|
120
|
+
lies in ``[start, end)``, so each trip lands in exactly the one
|
|
121
|
+
chunk owning its start and the boundary duplicate never persists.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
base_url: Root of the Samsara API, trailing-slash-normalized by
|
|
125
|
+
the provider config so the leading-slash path joins
|
|
126
|
+
directly.
|
|
127
|
+
path: The endpoint's leading-slash request path
|
|
128
|
+
(``'/v1/fleet/trips'``).
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
base_url: str
|
|
132
|
+
path: str
|
|
133
|
+
|
|
134
|
+
def build_spec(
|
|
135
|
+
self, resume: ResumeValue, member_values: Mapping[str, str]
|
|
136
|
+
) -> RequestSpec:
|
|
137
|
+
"""Build one vehicle chain's windowed GET.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
resume: The run's resume window. Must be a ``DateWindow`` --
|
|
141
|
+
a watermark endpoint always resumes from one; any other
|
|
142
|
+
value is a wiring bug.
|
|
143
|
+
member_values: The fan-out member binding, merged verbatim
|
|
144
|
+
as query parameters -- ``{'vehicleId': <id>}`` for a
|
|
145
|
+
roster chain (``vehicleId`` is REQUIRED by the wire; an
|
|
146
|
+
empty binding would earn the provider's own loud 400).
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
A credential-less ``GET`` for ``base_url + path`` carrying
|
|
150
|
+
the member binding plus ``startMs``/``endMs`` epoch
|
|
151
|
+
milliseconds for the window's bounds. Auth headers are
|
|
152
|
+
layered on by the client's ``ProviderProfile``.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
TypeError: ``resume`` is not a ``DateWindow``.
|
|
156
|
+
ValueError: A window bound is not canonical UTC.
|
|
157
|
+
|
|
158
|
+
Side Effects:
|
|
159
|
+
None.
|
|
160
|
+
"""
|
|
161
|
+
resume_window = require_date_window(resume, type(self).__name__)
|
|
162
|
+
params = dict(member_values)
|
|
163
|
+
params['startMs'] = str(_to_epoch_milliseconds(resume_window.start))
|
|
164
|
+
params['endMs'] = str(_to_epoch_milliseconds(resume_window.end))
|
|
165
|
+
return RequestSpec(
|
|
166
|
+
method=HttpMethod.GET,
|
|
167
|
+
url=f'{self.base_url}{self.path}',
|
|
168
|
+
params=params,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def build_endpoint(config: SamsaraConfig) -> EndpointDefinition[Trip]:
|
|
173
|
+
"""Build the Samsara trips watermark binding.
|
|
174
|
+
|
|
175
|
+
Per-vehicle trip history fetched incrementally: the run resumes from
|
|
176
|
+
a ``DateWindow`` (watermark with the provider's late-arrival
|
|
177
|
+
lookback from config -- which also absorbs trips materializing on
|
|
178
|
+
completion), the fetched trips are written to ``date=YYYY-MM-DD``
|
|
179
|
+
partitions on ``start_time``, and each refetched partition is
|
|
180
|
+
replaced. Records arrive as a top-level list under ``trips``,
|
|
181
|
+
unpaginated -- one response per (vehicle, window). The
|
|
182
|
+
``request_shape`` declaration (``RosterFanOut``) names the Samsara
|
|
183
|
+
``vehicle_ids`` roster; the orchestration entry resolves it to
|
|
184
|
+
members and fans one request chain per vehicle, passing each id to
|
|
185
|
+
the spec-builder's ``member_values`` -- this binding only declares
|
|
186
|
+
the strategies and the roster key, never the roster's feeder.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
config: The validated Samsara configuration; supplies the base
|
|
190
|
+
URL the spec-builder joins to the trips path and the
|
|
191
|
+
lookback and cutoff the watermark mode carries.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
The frozen trips ``EndpointDefinition``. Construction validates
|
|
195
|
+
the ``WatermarkMode`` / ``DATE_PARTITIONED`` /
|
|
196
|
+
``event_time_column`` triple against the response model.
|
|
197
|
+
"""
|
|
198
|
+
return EndpointDefinition(
|
|
199
|
+
provider=Provider.SAMSARA,
|
|
200
|
+
name='trips',
|
|
201
|
+
spec_builder=SamsaraTripsSpecBuilder(
|
|
202
|
+
base_url=config.base_url, path=_TRIPS_PATH
|
|
203
|
+
),
|
|
204
|
+
page_decoder=SinglePageDecoder(records_key=_RECORDS_KEY),
|
|
205
|
+
response_model=Trip,
|
|
206
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
207
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
208
|
+
sync_mode=WatermarkMode(
|
|
209
|
+
lookback=timedelta(days=config.lookback_days),
|
|
210
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
211
|
+
),
|
|
212
|
+
event_time_column='start_time',
|
|
213
|
+
request_shape=RosterFanOut(
|
|
214
|
+
roster=RosterKey(Provider.SAMSARA, 'vehicle_ids'),
|
|
215
|
+
member_key=_VEHICLE_ID_PARAM,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# src/fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py
|
|
2
|
+
"""The Samsara vehicle_fuel_energy_reports binding: the window-grain
|
|
3
|
+
rollup surface -- the first fixed-unit-width watermark endpoint
|
|
4
|
+
(probe-settled 2026-07-21, DESIGN section 8). The legacy hub's
|
|
5
|
+
``vehicle_fuel_energy``, renamed per the name=snake-plural-of-model
|
|
6
|
+
invariant (model ``VehicleFuelEnergyReport``).
|
|
7
|
+
|
|
8
|
+
``GET /fleet/reports/vehicles/fuel-energy`` carries the standard
|
|
9
|
+
``pagination {endCursor, hasNextPage}`` cursor contract, but the record
|
|
10
|
+
list is NESTED: ``data`` is an OBJECT whose only key is
|
|
11
|
+
``vehicleReports``, a list of report objects -- the
|
|
12
|
+
``SamsaraWindowReportPageDecoder``'s shape. Reports are fleet-wide with
|
|
13
|
+
per-record vehicle attribution, so there is NO fan-out -- the default
|
|
14
|
+
``SingleFetch`` shape, declared by declaring nothing, and no roster is
|
|
15
|
+
sourced or consumed.
|
|
16
|
+
|
|
17
|
+
**THE ROLLUP GRAIN IS THE REQUEST WINDOW, proven twice:** widening a
|
|
18
|
+
1-day window to 2 days GREW per-vehicle metrics (36 of 47 vehicles
|
|
19
|
+
shared between the 1-day walk and the 2-day window's first page grew),
|
|
20
|
+
and summing two adjacent day rollups reproduced the two-day
|
|
21
|
+
rollup on only 178 of 267 vehicles (89/267 MISMATCHED across distance,
|
|
22
|
+
engine run time, fuel, and energy). Day rows are NOT a lossless
|
|
23
|
+
decomposition of wider windows -- each row is the provider's answer for
|
|
24
|
+
exactly its window. Consequence: the binding declares
|
|
25
|
+
``fixed_unit_days=1`` on its ``WatermarkMode`` -- the unit width is
|
|
26
|
+
part of the ROW'S MEANING, so it must never float with
|
|
27
|
+
``sync.backfill_chunk_days``. Rows carry NO event-time key of any kind;
|
|
28
|
+
the decoder stamps each with the sent window, and
|
|
29
|
+
``event_time_column='window_start'`` routes each day's rollup to its
|
|
30
|
+
own partition (the vehicle-presence union across day windows holds:
|
|
31
|
+
the two day windows' 145- and 242-vehicle sets union to exactly the
|
|
32
|
+
two-day walk's 267, so per-day fetches lose no vehicle).
|
|
33
|
+
|
|
34
|
+
The ``limit`` param is PROVEN IGNORED (the assignments placebo
|
|
35
|
+
posture): limit=512, 513, and 10 on the 2-day window all returned
|
|
36
|
+
identical paging (3 pages, 267 reports), and 513 was NOT rejected -- no
|
|
37
|
+
enforced tier. The declared ``results_limit=100`` is
|
|
38
|
+
documentation-by-declaration of the server's OWN observed ~100-report
|
|
39
|
+
page size, not a working knob.
|
|
40
|
+
|
|
41
|
+
The window rides this surface family's own ``startDate``/``endDate``
|
|
42
|
+
param NAMES (full RFC3339 datetimes accepted despite the names --
|
|
43
|
+
unlike every other probed Samsara vertical's ``startTime``/``endTime``;
|
|
44
|
+
the shared ``SamsaraFuelEnergyReportSpecBuilder`` carries the
|
|
45
|
+
provenance).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from datetime import timedelta
|
|
49
|
+
from typing import Final
|
|
50
|
+
|
|
51
|
+
from fleetpull.config import SamsaraConfig
|
|
52
|
+
from fleetpull.endpoints.samsara._spec_builders import (
|
|
53
|
+
SamsaraFuelEnergyReportSpecBuilder,
|
|
54
|
+
)
|
|
55
|
+
from fleetpull.endpoints.shared import (
|
|
56
|
+
EndpointDefinition,
|
|
57
|
+
StorageKind,
|
|
58
|
+
WatermarkMode,
|
|
59
|
+
)
|
|
60
|
+
from fleetpull.models.samsara import VehicleFuelEnergyReport
|
|
61
|
+
from fleetpull.network.decoders import SamsaraWindowReportPageDecoder
|
|
62
|
+
from fleetpull.vocabulary import Provider, QuotaScope
|
|
63
|
+
|
|
64
|
+
__all__: list[str] = ['build_endpoint']
|
|
65
|
+
|
|
66
|
+
_VEHICLE_FUEL_ENERGY_PATH: Final[str] = '/fleet/reports/vehicles/fuel-energy'
|
|
67
|
+
_RECORDS_KEY: Final[str] = 'data'
|
|
68
|
+
|
|
69
|
+
# The nested report key: `data` is an OBJECT whose only key is this
|
|
70
|
+
# arm's report list (captured 2026-07-21).
|
|
71
|
+
_REPORT_KEY: Final[str] = 'vehicleReports'
|
|
72
|
+
|
|
73
|
+
# The per-page record count. ~100 is the server's OWN observed page
|
|
74
|
+
# size, and the `limit` param is PROVEN IGNORED on this surface:
|
|
75
|
+
# limit=512, 513, and 10 on the same 2-day window all returned
|
|
76
|
+
# identical paging (3 pages, 267 reports); 513 was NOT rejected (no
|
|
77
|
+
# enforced tier, captured 2026-07-21). Declaring 100 documents the
|
|
78
|
+
# server's paging; it is not a working knob.
|
|
79
|
+
_RESULTS_LIMIT: Final[int] = 100
|
|
80
|
+
|
|
81
|
+
# The fixed work-unit width, in whole days. The rollup grain is the
|
|
82
|
+
# request window (metrics GREW when the window widened) and day rollups
|
|
83
|
+
# are NON-ADDITIVE into wider windows (89/267 mismatched, captured
|
|
84
|
+
# 2026-07-21), so the unit width is part of the row's meaning and is
|
|
85
|
+
# pinned here rather than left to sync.backfill_chunk_days.
|
|
86
|
+
_FIXED_UNIT_DAYS: Final[int] = 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_endpoint(
|
|
90
|
+
config: SamsaraConfig,
|
|
91
|
+
) -> EndpointDefinition[VehicleFuelEnergyReport]:
|
|
92
|
+
"""Build the Samsara vehicle_fuel_energy_reports watermark binding.
|
|
93
|
+
|
|
94
|
+
Fleet-wide per-vehicle fuel-energy rollups fetched incrementally at
|
|
95
|
+
the fixed 1-day unit width: the run resumes from a ``DateWindow``
|
|
96
|
+
(watermark with the provider's late-arrival lookback from config),
|
|
97
|
+
the planner tiles it into exactly-one-day units (the declared
|
|
98
|
+
``fixed_unit_days`` -- module docstring for the non-additivity
|
|
99
|
+
proof), each unit's reports are stamped with its window by the
|
|
100
|
+
decoder and written to the ``date=YYYY-MM-DD`` partition on
|
|
101
|
+
``window_start``, and each refetched partition is replaced. Reports
|
|
102
|
+
arrive nested under ``data.vehicleReports``, walked by explicit
|
|
103
|
+
cursor pages (``limit`` on page one, ``after`` merged thereafter,
|
|
104
|
+
the ``startDate``/``endDate`` window persisting throughout),
|
|
105
|
+
terminal on ``hasNextPage: false``. No request shape is declared --
|
|
106
|
+
the endpoint is a fleet-wide ``SingleFetch``, the default.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
config: The validated Samsara configuration; supplies the base
|
|
110
|
+
URL the spec-builder joins to the report path and the
|
|
111
|
+
lookback and cutoff the watermark mode carries.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
The frozen vehicle_fuel_energy_reports ``EndpointDefinition``.
|
|
115
|
+
Construction validates the ``WatermarkMode`` /
|
|
116
|
+
``DATE_PARTITIONED`` / ``event_time_column`` triple against the
|
|
117
|
+
response model.
|
|
118
|
+
"""
|
|
119
|
+
return EndpointDefinition(
|
|
120
|
+
provider=Provider.SAMSARA,
|
|
121
|
+
name='vehicle_fuel_energy_reports',
|
|
122
|
+
spec_builder=SamsaraFuelEnergyReportSpecBuilder(
|
|
123
|
+
base_url=config.base_url, path=_VEHICLE_FUEL_ENERGY_PATH
|
|
124
|
+
),
|
|
125
|
+
page_decoder=SamsaraWindowReportPageDecoder(
|
|
126
|
+
records_key=_RECORDS_KEY,
|
|
127
|
+
report_key=_REPORT_KEY,
|
|
128
|
+
results_limit=_RESULTS_LIMIT,
|
|
129
|
+
),
|
|
130
|
+
response_model=VehicleFuelEnergyReport,
|
|
131
|
+
quota_scope=QuotaScope.SAMSARA,
|
|
132
|
+
storage_kind=StorageKind.DATE_PARTITIONED,
|
|
133
|
+
sync_mode=WatermarkMode(
|
|
134
|
+
lookback=timedelta(days=config.lookback_days),
|
|
135
|
+
cutoff=timedelta(days=config.cutoff_days),
|
|
136
|
+
fixed_unit_days=_FIXED_UNIT_DAYS,
|
|
137
|
+
),
|
|
138
|
+
event_time_column='window_start',
|
|
139
|
+
)
|