@fleetbase/fleetops-engine 0.6.66 → 0.6.67
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 +15 -8
- 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/composer.json +2 -1
- package/docs/AFAQY.md +71 -17
- package/docs/SAFEE.md +125 -0
- package/docs/TELEMATICS_QUEUES.md +179 -0
- package/extension.json +1 -1
- package/package.json +1 -1
- 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 +127 -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/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 +43 -1
- package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
- package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -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 +461 -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/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
|
@@ -38,7 +38,8 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
38
38
|
|
|
39
39
|
public function handle(Ingestor $ingestor, TelematicService $service): void
|
|
40
40
|
{
|
|
41
|
-
$
|
|
41
|
+
$started = microtime(true);
|
|
42
|
+
$lock = Cache::lock('telemetry:delivery:' . $this->deliveryUuid, 90);
|
|
42
43
|
if (!$lock->get()) {
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
@@ -66,21 +67,48 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
66
67
|
return;
|
|
67
68
|
}
|
|
68
69
|
$this->update(['status' => 'processing', 'attempts' => $row->attempts + 1, 'available_at' => now()->addSeconds(120)]);
|
|
70
|
+
$failed = [];
|
|
71
|
+
$failureTypes = [];
|
|
69
72
|
try {
|
|
70
|
-
$
|
|
73
|
+
$payload = json_decode(Crypt::decryptString($row->retry_payload ?? $row->payload), true, 512, JSON_THROW_ON_ERROR);
|
|
74
|
+
if ($row->retry_payload && ($payload['checkpoint_version'] ?? null) === 1) {
|
|
75
|
+
// This envelope is internal and encrypted; the original webhook
|
|
76
|
+
// contract was already validated before creating the checkpoint.
|
|
77
|
+
$units = $payload['remaining'] ?? null;
|
|
78
|
+
$failed = $payload['failed'] ?? null;
|
|
79
|
+
$failureTypes = $payload['failure_types'] ?? null;
|
|
80
|
+
foreach ([$units, $failed, $failureTypes] as $items) {
|
|
81
|
+
if (!is_array($items) || !array_is_list($items)) {
|
|
82
|
+
throw new \InvalidArgumentException('Invalid delivery checkpoint.');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} elseif ($row->source === 'poll') {
|
|
86
|
+
// Polling persists the provider's inventory records, including units
|
|
87
|
+
// that have never reported a position. Validate this internal envelope
|
|
88
|
+
// here and let ingestion report invalid positions per unit below.
|
|
89
|
+
if (!is_array($payload) || !array_is_list($payload) || $payload === []) {
|
|
90
|
+
throw new \InvalidArgumentException('Expected a non-empty polling unit list.');
|
|
91
|
+
}
|
|
92
|
+
$units = $payload;
|
|
93
|
+
} else {
|
|
94
|
+
// External deliveries must still pass the provider's position contract.
|
|
95
|
+
$units = $provider->telemetryUnits($payload);
|
|
96
|
+
}
|
|
71
97
|
} catch (\Throwable) {
|
|
72
98
|
$this->update(['status' => 'quarantined', 'failed' => 1, 'error' => 'Unsupported or unreadable position payload; inspect and replay after adapter correction.']);
|
|
73
99
|
Inbox::finishRun($row->run_uuid);
|
|
74
100
|
|
|
75
101
|
return;
|
|
76
102
|
}
|
|
77
|
-
$failed = [];
|
|
78
|
-
$failureTypes = [];
|
|
79
103
|
$applied = 0;
|
|
80
104
|
$invalid = (int) $row->invalid_count;
|
|
81
|
-
|
|
105
|
+
// Polled inventory legitimately includes units without a valid fix (for example,
|
|
106
|
+
// never reported). They stay counted, but only pushed samples are failures to replay.
|
|
107
|
+
$pushed = $row->source !== 'poll';
|
|
108
|
+
$sourceDelay = $row->source_delay_seconds;
|
|
82
109
|
$queueDelay = max(0, now()->timestamp - \Illuminate\Support\Carbon::parse($row->received_at, 'UTC')->timestamp);
|
|
83
|
-
|
|
110
|
+
$checkpointAt = $started;
|
|
111
|
+
foreach ($units as $index => $unit) {
|
|
84
112
|
try {
|
|
85
113
|
$result = $ingestor->ingest($telematic, $provider, $unit, $service, $row->received_at, $row->source);
|
|
86
114
|
$sourceDelay = max($sourceDelay ?? 0, data_get($result['device']->meta, 'telemetry.source_delay_seconds', 0));
|
|
@@ -93,19 +121,45 @@ class ProcessTelematicDelivery implements ShouldQueue, ShouldBeUnique
|
|
|
93
121
|
$failed[] = $unit;
|
|
94
122
|
$failureTypes[] = class_basename($e);
|
|
95
123
|
}
|
|
124
|
+
$elapsed = microtime(true);
|
|
125
|
+
$yield = $elapsed - $started >= 40 && $index + 1 < count($units);
|
|
126
|
+
if ($yield || ($index + 1) % 10 === 0 || $elapsed - $checkpointAt >= 2) {
|
|
127
|
+
// Persist progress independently of the worker reservation. A
|
|
128
|
+
// restart resumes the remaining tail rather than replaying a
|
|
129
|
+
// growing prefix until the delivery exhausts its retry budget.
|
|
130
|
+
$this->update([
|
|
131
|
+
'retry_payload' => Crypt::encryptString(json_encode([
|
|
132
|
+
'checkpoint_version' => 1, 'remaining' => array_slice($units, $index + 1),
|
|
133
|
+
'failed' => $failed, 'failure_types' => array_values(array_unique($failureTypes)),
|
|
134
|
+
], JSON_THROW_ON_ERROR)),
|
|
135
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
136
|
+
'queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay,
|
|
137
|
+
'status' => $yield ? 'retry' : 'processing',
|
|
138
|
+
// Cooperative continuation is not a failed attempt. Actual
|
|
139
|
+
// crashes keep the increment persisted at the start above.
|
|
140
|
+
'attempts' => $yield ? $row->attempts : $row->attempts + 1,
|
|
141
|
+
'available_at' => $yield ? now() : now()->addSeconds(120),
|
|
142
|
+
]);
|
|
143
|
+
$checkpointAt = $elapsed;
|
|
144
|
+
}
|
|
145
|
+
if ($yield) {
|
|
146
|
+
// The normal inbox drain queues the continuation after this
|
|
147
|
+
// job releases its uniqueness lease; it uses the same queue.
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
96
150
|
}
|
|
97
151
|
$this->update(['queue_delay_seconds' => $queueDelay, 'source_delay_seconds' => $sourceDelay]);
|
|
98
152
|
if ($failed && $row->attempts < 4) {
|
|
99
153
|
$this->update([
|
|
100
154
|
'status' => 'retry', 'retry_payload' => Crypt::encryptString(json_encode($failed, JSON_THROW_ON_ERROR)),
|
|
101
|
-
'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
|
|
155
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
102
156
|
'error' => 'Some units failed ingestion; retry scheduled. ' . implode(', ', array_unique($failureTypes)), 'available_at' => now()->addSeconds(15 * (2 ** $row->attempts)),
|
|
103
157
|
]);
|
|
104
158
|
} else {
|
|
105
159
|
$this->update([
|
|
106
|
-
'status' => ($failed || $invalid) ? 'quarantined' : 'processed',
|
|
107
|
-
'applied' => $row->applied + $applied, 'failed' => count($failed) + $invalid, 'invalid_count' => $invalid,
|
|
108
|
-
'error' => ($failed || $invalid) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
|
|
160
|
+
'status' => ($failed || ($pushed ? $invalid : 0)) ? 'quarantined' : 'processed',
|
|
161
|
+
'applied' => $row->applied + $applied, 'failed' => count($failed) + ($pushed ? $invalid : 0), 'invalid_count' => $invalid,
|
|
162
|
+
'error' => ($failed || ($pushed ? $invalid : 0)) ? 'Invalid positions or exhausted ingestion retries; inspect and replay.' : null,
|
|
109
163
|
'processed_at' => now(),
|
|
110
164
|
]);
|
|
111
165
|
}
|
|
@@ -40,6 +40,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
40
40
|
$this->telematic = $telematic;
|
|
41
41
|
$this->options = $options;
|
|
42
42
|
$this->jobId = $jobId ?? \Illuminate\Support\Str::uuid()->toString();
|
|
43
|
+
$this->onQueue(\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::pollQueue());
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
/**
|
|
@@ -47,6 +48,24 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
47
48
|
*/
|
|
48
49
|
public function handle(TelematicProviderRegistry $registry, TelematicService $service): void
|
|
49
50
|
{
|
|
51
|
+
// Jobs queued before a provider opts into batching must use the same bounded
|
|
52
|
+
// path as new manual requests, without acquiring the legacy hour-long lock.
|
|
53
|
+
$provider = $registry->resolve($this->telematic->provider);
|
|
54
|
+
if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
|
|
55
|
+
&& (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
|
|
56
|
+
$this->telematic->refresh();
|
|
57
|
+
if (\Fleetbase\FleetOps\Support\Telematics\Telemetry\Inbox::enabled($this->telematic)) {
|
|
58
|
+
try {
|
|
59
|
+
$service->queueTelemetrySync($this->telematic, $this->options, $this->jobId);
|
|
60
|
+
} catch (\Illuminate\Validation\ValidationException) {
|
|
61
|
+
// Paused or already queued/running. Failing here would mark the connection
|
|
62
|
+
// as errored and detach the active request's progress tracking.
|
|
63
|
+
Log::info('Skipped legacy telematics sync redirect.', ['telematic_uuid' => $this->telematic->uuid]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
50
69
|
$correlationId = \Illuminate\Support\Str::uuid()->toString();
|
|
51
70
|
$lockKey = 'fleetops:sync-telematic-devices:' . $this->telematic->uuid;
|
|
52
71
|
$lock = Cache::lock($lockKey, $this->timeout + 60);
|
|
@@ -96,12 +115,54 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
96
115
|
$totalEnrichment = 0;
|
|
97
116
|
$totalEnrichmentCompleted = 0;
|
|
98
117
|
$totalEnrichmentFailures = 0;
|
|
118
|
+
$itemsSinceCheckpoint = 0;
|
|
119
|
+
$lastCheckpointAt = now();
|
|
120
|
+
|
|
121
|
+
$checkpoint = function (string $phase, bool $force = false) use (
|
|
122
|
+
&$itemsSinceCheckpoint, &$lastCheckpointAt, &$providerSyncMeta, &$totalFetched,
|
|
123
|
+
&$totalLinked, &$totalLinkAttempts, &$totalEvents, &$totalSensors, &$totalSkipped,
|
|
124
|
+
&$pageCount, &$lastProviderAllCount, &$lastProviderFiltersCount,
|
|
125
|
+
&$inventoryFetched, &$inventoryLinked, &$inventorySkipped,
|
|
126
|
+
&$totalEnrichment, &$totalEnrichmentCompleted, &$totalEnrichmentFailures
|
|
127
|
+
): void {
|
|
128
|
+
if (!$force && $itemsSinceCheckpoint < 25 && $lastCheckpointAt->diffInSeconds(now(), true) < 5) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
$this->telematic->status = 'synchronizing';
|
|
132
|
+
$this->telematic->meta = array_merge($this->telematic->meta ?? [], $providerSyncMeta, [
|
|
133
|
+
'last_sync_job_id' => $this->jobId,
|
|
134
|
+
'last_sync_result' => 'running',
|
|
135
|
+
'last_sync_phase' => $phase,
|
|
136
|
+
'last_sync_progress_at' => now()->toDateTimeString(),
|
|
137
|
+
'last_sync_fetched_total' => $totalFetched,
|
|
138
|
+
'last_sync_linked_total' => $totalLinked,
|
|
139
|
+
'last_sync_link_attempts_total' => $totalLinkAttempts,
|
|
140
|
+
'last_sync_events_total' => $totalEvents,
|
|
141
|
+
'last_sync_sensors_total' => $totalSensors,
|
|
142
|
+
'last_sync_skipped_total' => $totalSkipped,
|
|
143
|
+
'last_sync_page_count' => $pageCount,
|
|
144
|
+
'last_sync_provider_total' => $lastProviderFiltersCount ?? $lastProviderAllCount,
|
|
145
|
+
'last_sync_provider_all_count' => $lastProviderAllCount,
|
|
146
|
+
'last_sync_provider_filters_count' => $lastProviderFiltersCount,
|
|
147
|
+
'last_sync_inventory_total' => $inventoryFetched,
|
|
148
|
+
'last_sync_inventory_linked_total' => $inventoryLinked,
|
|
149
|
+
'last_sync_inventory_skipped_total' => $inventorySkipped,
|
|
150
|
+
'last_sync_enrichment_total' => $totalEnrichment,
|
|
151
|
+
'last_sync_enrichment_completed' => $totalEnrichmentCompleted,
|
|
152
|
+
'last_sync_enrichment_failures' => $totalEnrichmentFailures,
|
|
153
|
+
]);
|
|
154
|
+
$this->telematic->save();
|
|
155
|
+
$itemsSinceCheckpoint = 0;
|
|
156
|
+
$lastCheckpointAt = now();
|
|
157
|
+
};
|
|
99
158
|
|
|
100
159
|
try {
|
|
160
|
+
$checkpoint('connecting', true);
|
|
101
161
|
$provider = $registry->resolve($this->telematic->provider);
|
|
102
162
|
$provider->connect($this->telematic);
|
|
103
163
|
|
|
104
164
|
do {
|
|
165
|
+
$checkpoint('fetching_inventory', true);
|
|
105
166
|
$response = $provider->fetchDevices([
|
|
106
167
|
'limit' => $this->options['limit'] ?? null,
|
|
107
168
|
'cursor' => $cursor,
|
|
@@ -137,6 +198,9 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
137
198
|
'has_more' => $response['has_more'] ?? false,
|
|
138
199
|
]);
|
|
139
200
|
|
|
201
|
+
// Persist retrieval before any potentially slow per-device database work.
|
|
202
|
+
$inventoryFetched = $totalFetched;
|
|
203
|
+
$checkpoint('ingesting_inventory', true);
|
|
140
204
|
foreach ($devices as $devicePayload) {
|
|
141
205
|
$normalizedDevice = $provider->normalizeDevice($devicePayload);
|
|
142
206
|
try {
|
|
@@ -162,7 +226,12 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
162
226
|
'imei' => $devicePayload['imei'] ?? null,
|
|
163
227
|
]);
|
|
164
228
|
}
|
|
229
|
+
$inventoryLinked = $totalLinked;
|
|
230
|
+
$inventorySkipped = $totalSkipped;
|
|
231
|
+
$itemsSinceCheckpoint++;
|
|
232
|
+
$checkpoint('ingesting_inventory');
|
|
165
233
|
}
|
|
234
|
+
$checkpoint('ingesting_inventory', true);
|
|
166
235
|
|
|
167
236
|
$cursor = $response['next_cursor'] ?? null;
|
|
168
237
|
|
|
@@ -221,6 +290,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
221
290
|
'device_count' => count($inventoryPayloads),
|
|
222
291
|
]);
|
|
223
292
|
|
|
293
|
+
$checkpoint('fetching_enrichment', true);
|
|
224
294
|
$enrichmentResponse = $provider->fetchDeviceTelemetrySnapshots($inventoryPayloads, [
|
|
225
295
|
'limit' => $this->options['limit'] ?? null,
|
|
226
296
|
'filters' => $this->options['filters'] ?? [],
|
|
@@ -229,6 +299,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
229
299
|
$enrichedDevices = $enrichmentResponse['devices'] ?? [];
|
|
230
300
|
$totalEnrichment += count($enrichedDevices);
|
|
231
301
|
|
|
302
|
+
$checkpoint('ingesting_enrichment', true);
|
|
232
303
|
foreach ($enrichedDevices as $devicePayload) {
|
|
233
304
|
$normalizedDevice = $provider->normalizeDevice($devicePayload);
|
|
234
305
|
try {
|
|
@@ -256,7 +327,10 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
256
327
|
'imei' => $devicePayload['imei'] ?? null,
|
|
257
328
|
]);
|
|
258
329
|
}
|
|
330
|
+
$itemsSinceCheckpoint++;
|
|
331
|
+
$checkpoint('ingesting_enrichment');
|
|
259
332
|
}
|
|
333
|
+
$checkpoint('ingesting_enrichment', true);
|
|
260
334
|
|
|
261
335
|
Log::info($this->telematic->provider === 'safee' ? 'Safee telemetry enrichment completed' : 'Telematics telemetry enrichment completed', [
|
|
262
336
|
'correlation_id' => $correlationId,
|
|
@@ -289,6 +363,8 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
289
363
|
'last_sync_job_id' => $this->jobId,
|
|
290
364
|
'last_sync_completed_at' => now()->toDateTimeString(),
|
|
291
365
|
'last_sync_result' => 'success',
|
|
366
|
+
'last_sync_phase' => 'completed',
|
|
367
|
+
'last_sync_progress_at' => now()->toDateTimeString(),
|
|
292
368
|
'last_sync_total' => $totalLinked,
|
|
293
369
|
'last_sync_fetched_total' => $totalFetched,
|
|
294
370
|
'last_sync_linked_total' => $totalLinked,
|
|
@@ -310,7 +386,7 @@ class SyncTelematicDevicesJob implements ShouldQueue
|
|
|
310
386
|
'last_sync_error_context' => null,
|
|
311
387
|
]);
|
|
312
388
|
$this->telematic->save();
|
|
313
|
-
} catch (\
|
|
389
|
+
} catch (\Throwable $e) {
|
|
314
390
|
$failureContext = method_exists($e, 'context') ? $e->context() : [];
|
|
315
391
|
$failureMessage = $this->safeSyncErrorMessage($e);
|
|
316
392
|
|
|
@@ -149,8 +149,10 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
149
149
|
$schedule->command('fleetops:process-maintenance-triggers')->daily()->withoutOverlapping()->storeOutputInDb();
|
|
150
150
|
$schedule->command('fleetops:send-maintenance-reminders')->daily()->withoutOverlapping()->storeOutputInDb();
|
|
151
151
|
$schedule->command('fleetops:process-operational-alerts')->everyMinute()->withoutOverlapping()->storeOutputInDb();
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// These commands dispatch bounded jobs. A scheduler restart must not
|
|
153
|
+
// leave the default 24-hour overlap lease blocking telemetry recovery.
|
|
154
|
+
$schedule->command('fleetops:sync-telematics')->everyMinute()->withoutOverlapping(2)->storeOutputInDb();
|
|
155
|
+
$schedule->command('fleetops:drain-telematic-inbox')->everyMinute()->withoutOverlapping(2);
|
|
154
156
|
});
|
|
155
157
|
$this->registerNotifications();
|
|
156
158
|
$this->registerAiCapabilities();
|
|
@@ -196,6 +198,7 @@ class FleetOpsServiceProvider extends CoreServiceProvider
|
|
|
196
198
|
$this->mergeConfigFrom(__DIR__ . '/../../config/fleetops.php', 'fleetops');
|
|
197
199
|
$this->mergeConfigFrom(__DIR__ . '/../../config/telematics.php', 'telematics');
|
|
198
200
|
$this->mergeConfigFrom(__DIR__ . '/../../config/afaqy.php', 'telematics.afaqy');
|
|
201
|
+
$this->mergeConfigFrom(__DIR__ . '/../../config/safee.php', 'telematics.safee');
|
|
199
202
|
$this->mergeConfigFrom(__DIR__ . '/../../config/telemetry.php', 'telematics.telemetry');
|
|
200
203
|
$this->mergeConfigFrom(__DIR__ . '/../../config/fuel-providers.php', 'fuel-providers');
|
|
201
204
|
$this->mergeConfigFrom(__DIR__ . '/../../config/api.php', 'api');
|
|
@@ -5,6 +5,7 @@ namespace Fleetbase\FleetOps\Support\Telematics\Providers;
|
|
|
5
5
|
use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
|
|
6
6
|
use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
|
|
7
7
|
use Fleetbase\FleetOps\Support\Telematics\Afaqy\Payload;
|
|
8
|
+
use Illuminate\Contracts\Encryption\DecryptException;
|
|
8
9
|
use Illuminate\Http\Client\ConnectionException;
|
|
9
10
|
use Illuminate\Http\Client\Response;
|
|
10
11
|
use Illuminate\Support\Facades\Cache;
|
|
@@ -20,12 +21,13 @@ use Illuminate\Support\Facades\Log;
|
|
|
20
21
|
*/
|
|
21
22
|
class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
|
|
22
23
|
{
|
|
23
|
-
protected string $baseUrl
|
|
24
|
-
protected int $requestsPerMinute
|
|
25
|
-
protected int $dataTimeout
|
|
26
|
-
protected int $connectTimeout
|
|
27
|
-
protected int $connectionTestTimeout
|
|
28
|
-
protected int $connectionTestConnectTimeout
|
|
24
|
+
protected string $baseUrl = 'https://api.afaqy.sa';
|
|
25
|
+
protected int $requestsPerMinute = 60;
|
|
26
|
+
protected int $dataTimeout = 120;
|
|
27
|
+
protected int $connectTimeout = 15;
|
|
28
|
+
protected int $connectionTestTimeout = 30;
|
|
29
|
+
protected int $connectionTestConnectTimeout = 10;
|
|
30
|
+
protected ?float $requestDeadline = null;
|
|
29
31
|
|
|
30
32
|
protected function prepareAuthentication(): void
|
|
31
33
|
{
|
|
@@ -343,9 +345,11 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
343
345
|
}
|
|
344
346
|
|
|
345
347
|
$this->reserveRequest();
|
|
348
|
+
$timeout = $this->remainingRequestTimeout(30);
|
|
346
349
|
$response = Http::asJson()
|
|
347
350
|
->acceptJson()
|
|
348
|
-
->timeout(
|
|
351
|
+
->timeout($timeout)
|
|
352
|
+
->connectTimeout(min($this->connectTimeout, $timeout))
|
|
349
353
|
->post($this->baseUrl . '/auth/login', [
|
|
350
354
|
'data' => [
|
|
351
355
|
'username' => $this->credentials['username'],
|
|
@@ -373,6 +377,20 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
373
377
|
}
|
|
374
378
|
|
|
375
379
|
protected function authenticatedPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, bool $allowRetry = true, ?int $timeout = null, ?int $connectTimeout = null): array
|
|
380
|
+
{
|
|
381
|
+
$previousDeadline = $this->requestDeadline;
|
|
382
|
+
$this->requestDeadline ??= $this->requestTime() + max(1, $timeout ?? $this->dataTimeout);
|
|
383
|
+
|
|
384
|
+
try {
|
|
385
|
+
return $this->postWithinDeadline($endpoint, $payload, $tokenInQuery, $allowRetry, $timeout, $connectTimeout);
|
|
386
|
+
} finally {
|
|
387
|
+
// Providers may be reused. A completed or failed request must not
|
|
388
|
+
// leave its deadline on the next independent request.
|
|
389
|
+
$this->requestDeadline = $previousDeadline;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
protected function postWithinDeadline(string $endpoint, array $payload, bool $tokenInQuery, bool $allowRetry, ?int $timeout, ?int $connectTimeout): array
|
|
376
394
|
{
|
|
377
395
|
[$url, $body] = $this->buildAuthenticatedRequest($endpoint, $payload, $tokenInQuery);
|
|
378
396
|
|
|
@@ -381,6 +399,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
381
399
|
$connectTimeout ??= $this->connectTimeout;
|
|
382
400
|
|
|
383
401
|
$this->reserveRequest();
|
|
402
|
+
$timeout = $this->remainingRequestTimeout($timeout);
|
|
403
|
+
$connectTimeout = min(max(1, $connectTimeout), $timeout);
|
|
384
404
|
try {
|
|
385
405
|
$response = Http::withHeaders($this->headers)
|
|
386
406
|
->timeout($timeout)
|
|
@@ -468,9 +488,22 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
468
488
|
{
|
|
469
489
|
$key = 'afaqy:token:' . $this->accountKey() . ':' . hash('sha256', $this->credentials['password'] ?? '');
|
|
470
490
|
|
|
471
|
-
return Cache::lock($key . ':lock', 40)->block(5, function () use ($key, $rejected) {
|
|
491
|
+
return Cache::lock($key . ':lock', 40)->block($this->remainingRequestTimeout(5), function () use ($key, $rejected) {
|
|
472
492
|
$stored = Cache::get($key);
|
|
473
|
-
$token =
|
|
493
|
+
$token = null;
|
|
494
|
+
if ($stored) {
|
|
495
|
+
try {
|
|
496
|
+
$token = Crypt::decryptString($stored);
|
|
497
|
+
} catch (DecryptException) {
|
|
498
|
+
// A cached token is disposable. Evict only this entry while holding
|
|
499
|
+
// the existing account lock, then authenticate with resolved credentials.
|
|
500
|
+
Cache::forget($key);
|
|
501
|
+
Log::warning('AFAQY cached token could not be decrypted; re-authenticating.', [
|
|
502
|
+
'telematic_uuid' => $this->telematic?->uuid,
|
|
503
|
+
'reason' => 'cached_token_decryption_failed',
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
474
507
|
if (!$token || $token === $rejected) {
|
|
475
508
|
$token = $this->authenticate();
|
|
476
509
|
Cache::put($key, Crypt::encryptString($token), 29 * 86400);
|
|
@@ -483,7 +516,8 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
483
516
|
protected function reserveRequest(): void
|
|
484
517
|
{
|
|
485
518
|
$key = 'afaqy:rate:' . $this->accountKey();
|
|
486
|
-
Cache::lock($key . ':lock', 5)->block(2, function () use ($key) {
|
|
519
|
+
Cache::lock($key . ':lock', 5)->block($this->remainingRequestTimeout(2), function () use ($key) {
|
|
520
|
+
$this->remainingRequestTimeout(1);
|
|
487
521
|
$now = microtime(true);
|
|
488
522
|
$until = (float) Cache::get($key . ':blocked', 0);
|
|
489
523
|
$requests = array_values(array_filter(Cache::get($key, []), fn ($at) => $at > $now - 60));
|
|
@@ -495,6 +529,27 @@ class AfaqyProvider extends AbstractProvider implements \Fleetbase\FleetOps\Cont
|
|
|
495
529
|
});
|
|
496
530
|
}
|
|
497
531
|
|
|
532
|
+
/** Monotonic time keeps the request budget independent of wall-clock corrections. */
|
|
533
|
+
protected function requestTime(): float
|
|
534
|
+
{
|
|
535
|
+
return hrtime(true) / 1_000_000_000;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
protected function remainingRequestTimeout(int $maximum): int
|
|
539
|
+
{
|
|
540
|
+
$maximum = max(1, $maximum);
|
|
541
|
+
if ($this->requestDeadline === null) {
|
|
542
|
+
return $maximum;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
$remaining = (int) floor($this->requestDeadline - $this->requestTime());
|
|
546
|
+
if ($remaining < 1) {
|
|
547
|
+
throw new TelematicProviderException('AFAQY request time budget exhausted before another operation could start.');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return min($maximum, $remaining);
|
|
551
|
+
}
|
|
552
|
+
|
|
498
553
|
protected function checkThrottle(Response $response): void
|
|
499
554
|
{
|
|
500
555
|
if ($response->status() !== 429) {
|