@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.
- package/RELEASE.md +9 -8
- package/addon/components/fuel-integration/form.hbs +2 -2
- package/addon/components/fuel-integration/form.js +8 -0
- package/addon/components/fuel-integration/hub.js +1 -1
- package/addon/components/telematic/details.hbs +1 -1
- package/addon/components/telematic/telemetry-status.hbs +93 -34
- package/addon/components/widget/radar.hbs +1 -1
- package/addon/components/widget/radar.js +7 -0
- package/addon/templates/connectivity/fuel-providers/details/index.hbs +1 -1
- package/addon/utils/fuel-transaction.js +2 -0
- package/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +130 -0
- package/docs/TELEMATICS_QUEUES.md +230 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/config/fuel-providers.php +53 -0
- package/server/config/safee.php +10 -0
- package/server/config/telematics.php +8 -1
- package/server/config/telemetry.php +4 -1
- package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
- package/server/src/Console/Commands/SyncTelematics.php +11 -5
- package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
- package/server/src/Events/TrailerLocationChanged.php +2 -0
- package/server/src/Events/VehicleLocationChanged.php +5 -0
- package/server/src/Jobs/PollTelematicTelemetry.php +163 -12
- package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
- package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
- package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
- package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
- package/server/src/Support/FuelProviders/Providers/SascoFuelProvider.php +272 -0
- package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
- package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
- package/server/src/Support/Telematics/Safee/Transport.php +210 -0
- package/server/src/Support/Telematics/TelematicService.php +45 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +49 -3
- package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
- package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
- package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
- package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +545 -0
- package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
- package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
- package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
- package/server/tests/ProviderContractsTest.php +50 -1
- package/server/tests/Support/AfaqyTestCrypto.php +6 -23
- package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
- package/server/tests/TelematicsHardeningTest.php +18 -13
- package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
- package/server/tests/Unit/Support/SascoFuelProviderTest.php +338 -0
- package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
- package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
- package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
- package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
- 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\
|
|
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
|
|
17
|
-
protected int $requestsPerMinute
|
|
18
|
-
protected ?string $accessToken
|
|
19
|
-
protected array $authContext
|
|
20
|
-
protected int $dataTimeout
|
|
21
|
-
protected int $connectTimeout
|
|
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
|
-
|
|
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
|
-
$
|
|
66
|
-
$
|
|
67
|
-
$
|
|
68
|
-
$
|
|
69
|
-
$
|
|
70
|
-
$
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
'
|
|
78
|
-
'
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
'
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
'
|
|
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
|
-
$
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
$
|
|
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
|
|
734
|
-
|
|
735
|
-
$
|
|
736
|
-
|
|
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
|
-
|
|
741
|
-
|
|
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 $
|
|
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
|
-
|
|
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
|
-
|
|
773
|
-
|
|
843
|
+
return $timestamp ? Carbon::parse($timestamp)->utc()->toDateTimeString() : null;
|
|
844
|
+
}
|
|
774
845
|
|
|
775
|
-
|
|
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
|