@fleetbase/fleetops-engine 0.6.66 → 0.6.68

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.
Files changed (55) hide show
  1. package/RELEASE.md +9 -8
  2. package/addon/components/fuel-integration/form.hbs +2 -2
  3. package/addon/components/fuel-integration/form.js +8 -0
  4. package/addon/components/fuel-integration/hub.js +1 -1
  5. package/addon/components/telematic/details.hbs +1 -1
  6. package/addon/components/telematic/telemetry-status.hbs +93 -34
  7. package/addon/components/widget/radar.hbs +1 -1
  8. package/addon/components/widget/radar.js +7 -0
  9. package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
  10. package/addon/utils/fuel-transaction.js +2 -0
  11. package/composer.json +2 -1
  12. package/docs/AFAQY.md +71 -17
  13. package/docs/SAFEE.md +130 -0
  14. package/docs/TELEMATICS_QUEUES.md +230 -0
  15. package/extension.json +1 -1
  16. package/package.json +1 -1
  17. package/server/config/fuel-providers.php +53 -0
  18. package/server/config/safee.php +10 -0
  19. package/server/config/telematics.php +8 -1
  20. package/server/config/telemetry.php +4 -1
  21. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  22. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  23. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  24. package/server/src/Events/TrailerLocationChanged.php +2 -0
  25. package/server/src/Events/VehicleLocationChanged.php +5 -0
  26. package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
  27. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  28. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  29. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  30. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  31. package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
  32. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  33. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  34. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  35. package/server/src/Support/Telematics/TelematicService.php +45 -1
  36. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  37. package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
  38. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  39. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  40. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  41. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
  42. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  43. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  44. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  45. package/server/tests/ProviderContractsTest.php +50 -1
  46. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  47. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  48. package/server/tests/TelematicsHardeningTest.php +18 -13
  49. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  50. package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
  51. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  52. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  53. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  54. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  55. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
@@ -2,8 +2,12 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
4
 
5
+ use Fleetbase\FleetOps\Contracts\TelemetryProviderInterface;
6
+ use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
7
+ use Fleetbase\FleetOps\Support\Telematics\Safee\Transport;
8
+ use Fleetbase\FleetOps\Support\Telematics\Telemetry\Sample;
5
9
  use Illuminate\Support\Carbon;
6
- use Illuminate\Support\Facades\Http;
10
+ use Illuminate\Support\Facades\Cache;
7
11
 
8
12
  /**
9
13
  * Safee Tracking REST provider implementation.
@@ -11,19 +15,25 @@ use Illuminate\Support\Facades\Http;
11
15
  * Safee authenticates through an OpenID Connect token endpoint and exposes
12
16
  * vehicle discovery plus latest state/position endpoints under api/v2.
13
17
  */
14
- class SafeeProvider extends AbstractProvider
18
+ class SafeeProvider extends AbstractProvider implements TelemetryProviderInterface
15
19
  {
16
- protected string $baseUrl = 'https://api.safee.com';
17
- protected int $requestsPerMinute = 3000;
18
- protected ?string $accessToken = null;
19
- protected array $authContext = [];
20
- protected int $dataTimeout = 120;
21
- protected int $connectTimeout = 15;
20
+ protected string $baseUrl = 'https://api.safee.com';
21
+ protected int $requestsPerMinute = 3000;
22
+ protected ?string $accessToken = null;
23
+ protected array $authContext = [];
24
+ protected int $dataTimeout = 45;
25
+ protected int $connectTimeout = 5;
26
+ protected ?float $requestDeadline = null;
27
+ protected ?Transport $transport = null;
28
+ protected ?array $inventory = null;
22
29
 
23
30
  protected function prepareAuthentication(): void
24
31
  {
25
32
  $this->baseUrl = $this->resolveBaseUrl();
26
- $this->accessToken = $this->credentials['access_token'] ?? $this->authenticate();
33
+ // Authentication is lazy so it shares the caller's fetch deadline.
34
+ $this->accessToken = $this->credentials['access_token'] ?? null;
35
+ $this->transport = null;
36
+ $this->inventory = null;
27
37
  $scheme = $this->credentials['authorization_scheme'] ?? 'Bearer';
28
38
 
29
39
  $this->headers = [
@@ -62,57 +72,156 @@ class SafeeProvider extends AbstractProvider
62
72
 
63
73
  public function fetchDevices(array $options = []): array
64
74
  {
65
- $listInfoBody = $this->resolveListInfoPayload($options);
66
- $response = $this->safeePost('/api/v2/vehicle/list-info', $listInfoBody, true);
67
- $vehicles = $response['result'] ?? [];
68
- $vehicleIds = $this->resolveListedVehicleIds($vehicles);
69
- $identityStats = $this->summarizeVehicleIdentities($vehicles, $vehicleIds);
70
- $endpointStats = [
71
- 'vehicles_listed' => count($vehicles),
72
- 'unique_vehicle_ids' => $identityStats['unique_vehicle_ids'],
73
- 'missing_vehicle_ids' => $identityStats['missing_vehicle_ids'],
74
- 'duplicate_vehicle_ids' => $identityStats['duplicate_vehicle_ids'],
75
- 'list_info_page_size' => $listInfoBody['pageSize'] ?? null,
76
- 'list_info_requested_unpaginated' => ($listInfoBody['pageSize'] ?? null) === 0,
77
- 'last_state_fetched' => 0,
78
- 'last_info_fetched' => 0,
79
- 'positions_fetched' => 0,
80
- 'events_fetched' => 0,
81
- 'devices_returned_for_ingestion' => count($vehicles),
82
- 'failures' => [],
83
- ];
84
- $statesById = $this->fetchLastStatesByVehicle($vehicleIds, $endpointStats);
85
- $endpointStats['last_state_fetched'] = count($statesById);
86
-
87
- $devices = array_map(function (array $vehicle) use ($statesById, &$endpointStats) {
88
- $vehicleId = $this->resolveListedVehicleId($vehicle);
89
- $currentState = $statesById[(string) $vehicleId] ?? null;
90
-
91
- return array_merge($vehicle, [
92
- '_safee' => [
93
- 'vehicle_id' => $vehicleId,
94
- 'identity' => $vehicle,
95
- 'current_info' => null,
96
- 'current_state' => $currentState,
97
- 'positions' => [],
98
- 'events' => [],
99
- 'sync_window' => null,
100
- 'diagnostics' => $endpointStats,
101
- ],
102
- 'sensors' => [],
103
- ]);
104
- }, $vehicles);
75
+ $previousDeadline = $this->requestDeadline;
76
+ $previousTimeout = $this->dataTimeout;
77
+ $previousConnectTimeout = $this->connectTimeout;
78
+ $this->dataTimeout = max(1, (int) ($options['timeout'] ?? $this->dataTimeout));
79
+ $this->connectTimeout = max(1, min((int) ($options['connect_timeout'] ?? 5), $this->dataTimeout));
80
+ $this->requestDeadline = $this->requestTime() + $this->dataTimeout;
81
+ try {
82
+ $cursor = $options['cursor'] ?? 0;
83
+ if ((!is_int($cursor) && !ctype_digit((string) $cursor)) || (int) $cursor < 0) {
84
+ throw new TelematicProviderException('Safee inventory cursor is invalid.');
85
+ }
86
+ $offset = (int) $cursor;
87
+ $limit = max(1, min(1000, (int) ($options['limit'] ?? $options['page_size'] ?? $this->telemetryOptions()['page_size'])));
88
+ $filters = (array) ($options['filters'] ?? $options['filter'] ?? []);
89
+ if ($filters !== []) {
90
+ throw new TelematicProviderException('Safee list-info does not support filters; use an explicit historical or search request.');
91
+ }
92
+ $transport = $this->transport();
93
+ $cacheKey = 'safee:inventory:' . $transport->fingerprint();
94
+ $refresh = $offset === 0 && ($options['refresh_inventory'] ?? $options['force_inventory_refresh'] ?? false);
95
+ if ($offset === 0) {
96
+ $this->inventory = $refresh ? null : Cache::get($cacheKey);
97
+ }
98
+ if ($this->inventory === null) {
99
+ // list-info is documented as an unpaginated {} request. Pagination
100
+ // here slices this stable inventory; only live states are batched.
101
+ $response = $this->safeePost('/api/v2/vehicle/list-info', new \stdClass(), true);
102
+ $this->inventory = $this->validatedList($response, '/api/v2/vehicle/list-info');
103
+ $identities = [];
104
+ foreach ($this->inventory as $vehicle) {
105
+ $id = $this->resolveListedVehicleId($vehicle);
106
+ if (!is_scalar($id) || (string) $id === '' || isset($identities[(string) $id])) {
107
+ $this->inventory = null;
108
+ throw new TelematicProviderException('Safee inventory contains a missing or duplicate vehicle identity.');
109
+ }
110
+ $identities[(string) $id] = true;
111
+ }
112
+ Cache::put($cacheKey, $this->inventory, max(1, min(300, (int) ($this->telemetryOptions()['inventory_cache_seconds'] ?? 60))));
113
+ }
114
+ $total = count($this->inventory);
115
+ if ($offset > $total) {
116
+ throw new TelematicProviderException('Safee inventory cursor exceeds the inventory size.');
117
+ }
118
+ $vehicles = array_slice($this->inventory, $offset, $limit);
119
+ $vehicleIds = $this->resolveListedVehicleIds($vehicles);
120
+ $states = $this->fetchLastStatesByVehicle($vehicleIds);
121
+ $devices = array_map(function (array $vehicle) use ($states) {
122
+ $id = $this->resolveListedVehicleId($vehicle);
123
+ $state = $states[(string) $id] ?? null;
124
+
125
+ return array_merge($vehicle, ['_safee' => [
126
+ 'vehicle_id' => $id, 'identity' => $vehicle, 'current_state' => $state,
127
+ 'current_info' => null, 'positions' => [], 'events' => [], 'sync_window' => null,
128
+ ], 'sensors' => $this->extractTelemetrySensors($state ? array_merge($state, ['vehicleId' => $id]) : [])]);
129
+ }, $vehicles);
130
+ $next = $offset + count($vehicles) < $total ? $offset + count($vehicles) : null;
105
131
 
106
- return [
107
- 'devices' => $devices,
108
- 'next_cursor' => null,
109
- 'has_more' => false,
110
- 'sync_meta' => [
111
- 'safee_last_endpoint_counts' => array_merge($endpointStats, [
112
- 'failures' => array_slice($endpointStats['failures'], 0, 25),
113
- ]),
114
- ],
115
- ];
132
+ return [
133
+ 'devices' => $devices, 'next_cursor' => $next, 'has_more' => $next !== null,
134
+ 'pagination' => ['allCount' => $total, 'filtersCount' => $total, 'offset' => $offset, 'limit' => $limit, 'resultCount' => count($devices)],
135
+ 'sync_meta' => ['safee_last_endpoint_counts' => [
136
+ 'vehicles_listed' => $total, 'unique_vehicle_ids' => $total,
137
+ 'missing_vehicle_ids' => 0, 'duplicate_vehicle_ids' => [],
138
+ 'list_info_requested_unpaginated' => true, 'last_state_fetched' => count($states),
139
+ 'missing_states' => count($vehicles) - count($states),
140
+ 'last_info_fetched' => 0, 'positions_fetched' => 0, 'events_fetched' => 0,
141
+ 'devices_returned_for_ingestion' => count($devices), 'failures' => [],
142
+ ]],
143
+ ];
144
+ } finally {
145
+ $this->requestDeadline = $previousDeadline;
146
+ $this->dataTimeout = $previousTimeout;
147
+ $this->connectTimeout = $previousConnectTimeout;
148
+ }
149
+ }
150
+
151
+ public function telemetryOptions(): array
152
+ {
153
+ $options = array_replace(['polling_enabled' => true, 'manual_batch_sync' => true, 'page_size' => 1000, 'inventory_cache_seconds' => 60], config('telematics.safee', []));
154
+ $options['page_size'] = max(1, min(1000, (int) $options['page_size']));
155
+ $options['webhooks_enabled'] = false;
156
+
157
+ return $options;
158
+ }
159
+
160
+ public function telemetryUnits(array $payload): array
161
+ {
162
+ throw new \InvalidArgumentException('Safee does not support position webhooks.');
163
+ }
164
+
165
+ public function normalizeTelemetrySnapshot(array $payload): array
166
+ {
167
+ if (!isset($payload['_safee'])) {
168
+ $id = $this->resolveVehicleId($payload);
169
+ $payload['_safee'] = ['vehicle_id' => $id, 'identity' => array_replace($payload, ['id' => $id]), 'current_state' => $payload];
170
+ }
171
+ $identity = $this->identityPayload($payload);
172
+ $current = $this->currentTelemetryPayload($payload) ?? (isset($payload['_safee']) ? [] : $payload);
173
+ $device = $this->normalizeDevice($payload);
174
+ $device['name'] = $identity['plateNo'] ?? $identity['plateNumber'] ?? $identity['name'] ?? $current['plateNo'] ?? null;
175
+ $device['internal_id'] = $identity['uuid'] ?? null;
176
+ $device['status'] = null;
177
+ $device['online'] = null;
178
+ $event = $this->normalizeSafeeTelemetryEvent($current, 'current', $identity);
179
+ $event['device_id'] = $device['device_id'];
180
+ $event['occurred_at'] = $this->sourceTimestamp($current['date'] ?? $current['deviceTime'] ?? $current['time'] ?? null);
181
+ $event['last_seen_at'] = $event['occurred_at'];
182
+ $event['online'] = null;
183
+ $event['meta'] = ['telemetry' => ['position_at' => $event['occurred_at']]];
184
+ $device['last_seen_at'] = $event['occurred_at'];
185
+ $device['meta'] = $this->withoutMissingValues([
186
+ 'plate_number' => $identity['plateNo'] ?? $identity['plateNumber'] ?? $current['plateNo'] ?? null,
187
+ 'driver' => $current['driver'] ?? $identity['driver'] ?? null,
188
+ 'last_update' => $event,
189
+ ]);
190
+ $sensors = [];
191
+ foreach ($this->extractTelemetrySensors(array_merge($current, ['vehicleId' => $device['device_id']])) as $sensor) {
192
+ if (is_scalar($sensor['value'] ?? null)) {
193
+ $sensor = $this->normalizeSensor($sensor);
194
+ $sensor['recorded_at'] = $event['occurred_at'];
195
+ $sensors[] = $sensor;
196
+ }
197
+ }
198
+
199
+ return ['device' => array_filter($device, fn ($value) => $value !== null), 'event' => $event, 'sensors' => $sensors];
200
+ }
201
+
202
+ protected function withoutMissingValues(array $values): array
203
+ {
204
+ foreach ($values as $key => $value) {
205
+ if (is_array($value)) {
206
+ $values[$key] = $this->withoutMissingValues($value);
207
+ }
208
+ }
209
+
210
+ return array_filter($values, fn ($value) => $value !== null && $value !== []);
211
+ }
212
+
213
+ protected function validatedList(array $response, string $endpoint): array
214
+ {
215
+ if (!array_key_exists('code', $response) || !is_numeric($response['code']) || (float) $response['code'] !== 0.0 || !isset($response['result']) || !is_array($response['result']) || !array_is_list($response['result'])) {
216
+ throw new TelematicProviderException('Safee returned an invalid list response.', ['endpoint' => $endpoint]);
217
+ }
218
+ foreach ($response['result'] as $row) {
219
+ if (!is_array($row)) {
220
+ throw new TelematicProviderException('Safee returned an invalid list record.', ['endpoint' => $endpoint]);
221
+ }
222
+ }
223
+
224
+ return $response['result'];
116
225
  }
117
226
 
118
227
  public function fetchDeviceTelemetrySnapshots(array $inventoryPayloads, array $options = []): array
@@ -140,7 +249,7 @@ class SafeeProvider extends AbstractProvider
140
249
  return [
141
250
  'devices' => $devices,
142
251
  'sync_meta' => [
143
- 'safee_last_telemetry_synced_at' => $window['endDate'],
252
+ ...($endpointStats['failures'] === [] ? ['safee_last_telemetry_synced_at' => $window['endDate']] : []),
144
253
  'safee_last_sync_window' => $window,
145
254
  'safee_last_endpoint_counts' => array_merge($endpointStats, [
146
255
  'failures' => array_slice($endpointStats['failures'], 0, 25),
@@ -396,28 +505,7 @@ class SafeeProvider extends AbstractProvider
396
505
  $tokenUrl = $this->baseUrl . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token';
397
506
  $this->authContext = $this->buildAuthContext($tokenUrl);
398
507
 
399
- $response = Http::asForm()
400
- ->acceptJson()
401
- ->timeout(30)
402
- ->post($tokenUrl, [
403
- 'grant_type' => 'password',
404
- 'client_secret' => $this->credentials['client_secret'],
405
- 'client_id' => $this->credentials['client_id'],
406
- 'username' => $this->credentials['username'],
407
- 'password' => $this->credentials['password'],
408
- ]);
409
-
410
- if ($response->failed()) {
411
- throw new \RuntimeException('Safee authentication failed with status ' . $response->status());
412
- }
413
-
414
- $token = $response->json('access_token');
415
-
416
- if (!$token) {
417
- throw new \RuntimeException('Safee authentication did not return an access token.');
418
- }
419
-
420
- return $token;
508
+ return $this->transport()->authenticate($this->requestDeadline ?? $this->requestTime() + 30, min(30, $this->dataTimeout), $this->connectTimeout);
421
509
  }
422
510
 
423
511
  protected function resolveBaseUrl(): string
@@ -563,34 +651,16 @@ class SafeeProvider extends AbstractProvider
563
651
 
564
652
  $states = [];
565
653
  foreach (array_chunk($vehicleIds, 1000) as $chunk) {
566
- try {
567
- $response = $this->safeePost('/api/v2/vehicle/last-state', [
568
- 'live' => true,
569
- 'startDate' => null,
570
- 'endDate' => null,
571
- 'vehicles' => array_values($chunk),
572
- ], true);
573
- } catch (\Throwable $e) {
574
- if (is_array($endpointStats)) {
575
- $endpointStats['failures'][] = [
576
- 'endpoint' => '/api/v2/vehicle/last-state',
577
- 'vehicle_id' => null,
578
- 'message' => $this->sanitizeProviderMessage($e->getMessage()),
579
- ];
580
- }
581
-
582
- continue;
583
- }
584
-
585
- foreach ($response['result'] ?? [] as $state) {
586
- if (!is_array($state)) {
587
- continue;
588
- }
589
-
654
+ $response = $this->safeePost('/api/v2/vehicle/last-state', [
655
+ 'live' => true, 'endDate' => null, 'vehicles' => array_values($chunk),
656
+ ], true);
657
+ $requested = array_fill_keys(array_map('strval', $chunk), true);
658
+ foreach ($this->validatedList($response, '/api/v2/vehicle/last-state') as $state) {
590
659
  $vehicleId = $this->resolveVehicleId($state);
591
- if ($vehicleId !== null) {
592
- $states[(string) $vehicleId] = $state;
660
+ if (!is_scalar($vehicleId) || !isset($requested[(string) $vehicleId]) || isset($states[(string) $vehicleId])) {
661
+ throw new TelematicProviderException('Safee returned an unidentified, duplicate or unrequested vehicle state.');
593
662
  }
663
+ $states[(string) $vehicleId] = $state;
594
664
  }
595
665
  }
596
666
 
@@ -619,13 +689,13 @@ class SafeeProvider extends AbstractProvider
619
689
  return array_merge($vehicle, [
620
690
  '_safee' => [
621
691
  'vehicle_id' => $vehicleId,
622
- 'identity' => $vehicle,
692
+ 'identity' => $this->identityPayload($vehicle),
623
693
  'current_info' => $lastInfo,
624
694
  'current_state' => $currentState,
625
695
  'positions' => is_array($positions) ? $positions : [],
626
696
  'events' => is_array($events) ? $events : [],
627
697
  'sync_window' => $window,
628
- 'diagnostics' => $endpointStats,
698
+ 'diagnostics' => array_merge($endpointStats, ['failures' => array_slice($endpointStats['failures'], 0, 25)]),
629
699
  ],
630
700
  'sensors' => $this->extractTelemetrySensors($lastInfo ?? []),
631
701
  ]);
@@ -676,7 +746,15 @@ class SafeeProvider extends AbstractProvider
676
746
  $currentState = data_get($payload, '_safee.current_state');
677
747
 
678
748
  if (is_array($currentState) && !empty($currentState) && is_array($currentInfo) && !empty($currentInfo)) {
679
- return array_replace_recursive($currentState, $currentInfo);
749
+ $stateAt = $this->sourceTimestamp($currentState['date'] ?? $currentState['deviceTime'] ?? $currentState['time'] ?? null);
750
+ $infoAt = $this->sourceTimestamp($currentInfo['date'] ?? $currentInfo['deviceTime'] ?? $currentInfo['time'] ?? null);
751
+ // Only supplement absent fields with older observations. Never replace
752
+ // the latest position/time with the result of a slower enrichment call.
753
+ $infoIsNewer = $infoAt && (!$stateAt || strcmp($infoAt, $stateAt) > 0);
754
+
755
+ return $infoIsNewer
756
+ ? array_replace_recursive($this->withoutMissingValues($currentState), $this->withoutMissingValues($currentInfo))
757
+ : array_replace_recursive($this->withoutMissingValues($currentInfo), $this->withoutMissingValues($currentState));
680
758
  }
681
759
 
682
760
  if (is_array($currentInfo) && !empty($currentInfo)) {
@@ -717,31 +795,29 @@ class SafeeProvider extends AbstractProvider
717
795
 
718
796
  protected function safeeGet(string $endpoint): array
719
797
  {
720
- $response = Http::withHeaders($this->headers)
721
- ->timeout(30)
722
- ->get($this->baseUrl . $endpoint);
723
-
724
- if ($response->failed()) {
725
- throw new \RuntimeException('Safee API request failed with status ' . $response->status());
726
- }
727
-
728
- return $response->json() ?? [];
798
+ return $this->transport()->request('GET', $endpoint, [], $this->requestDeadline ?? $this->requestTime() + 30, 30, $this->connectTimeout);
729
799
  }
730
800
 
731
801
  protected function safeePost(string $endpoint, array|\stdClass $payload = [], bool $dataEndpoint = false): array
732
802
  {
733
- $timeout = $dataEndpoint ? $this->dataTimeout : 30;
734
- $connectTimeout = $dataEndpoint ? $this->connectTimeout : 10;
735
- $response = Http::withHeaders($this->headers)
736
- ->timeout($timeout)
737
- ->connectTimeout($connectTimeout)
738
- ->post($this->baseUrl . $endpoint, $payload);
803
+ $timeout = $dataEndpoint ? $this->dataTimeout : 30;
804
+
805
+ return $this->transport()->request('POST', $endpoint, $payload, $this->requestDeadline ?? $this->requestTime() + $timeout, $timeout, $this->connectTimeout);
806
+ }
739
807
 
740
- if ($response->failed()) {
741
- throw new \RuntimeException('Safee API request failed with status ' . $response->status());
808
+ protected function transport(): Transport
809
+ {
810
+ $this->baseUrl = $this->resolveBaseUrl();
811
+ if (!empty($this->credentials['realm_id'])) {
812
+ $this->authContext = $this->buildAuthContext($this->baseUrl . '/auth/realms/' . rawurlencode($this->credentials['realm_id']) . '/protocol/openid-connect/token');
742
813
  }
743
814
 
744
- return $response->json() ?? [];
815
+ return $this->transport ??= new Transport($this->baseUrl, $this->credentials);
816
+ }
817
+
818
+ protected function requestTime(): float
819
+ {
820
+ return hrtime(true) / 1_000_000_000;
745
821
  }
746
822
 
747
823
  protected function sanitizeProviderMessage(string $message): string
@@ -756,23 +832,21 @@ class SafeeProvider extends AbstractProvider
756
832
  return [
757
833
  'lat' => $position['lat'] ?? $position['latitude'] ?? data_get($position, 'loc.coordinates.1'),
758
834
  'lng' => $position['lon'] ?? $position['lng'] ?? $position['longitude'] ?? data_get($position, 'loc.coordinates.0'),
835
+ 'alt' => $position['alt'] ?? $position['altitude'] ?? null,
759
836
  ];
760
837
  }
761
838
 
762
839
  protected function parseTimestamp($value): ?string
763
840
  {
764
- if (!$value) {
765
- return null;
766
- }
767
-
768
- if (is_numeric($value)) {
769
- $timestamp = (float) $value;
770
- $seconds = $timestamp > 9999999999 ? $timestamp / 1000 : $timestamp;
841
+ $timestamp = $this->sourceTimestamp($value);
771
842
 
772
- return Carbon::createFromTimestamp($seconds)->toDateTimeString();
773
- }
843
+ return $timestamp ? Carbon::parse($timestamp)->utc()->toDateTimeString() : null;
844
+ }
774
845
 
775
- return Carbon::parse($value)->toDateTimeString();
846
+ protected function sourceTimestamp(mixed $value): ?string
847
+ {
848
+ // Safee uses zero for vehicles which have never reported a position.
849
+ return is_numeric($value) && (float) $value <= 0 ? null : Sample::timestamp($value);
776
850
  }
777
851
 
778
852
  protected function normalizeVehicleStatus(?string $status): string