@fleetbase/fleetops-engine 0.6.52 → 0.6.54

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 (151) hide show
  1. package/addon/components/device/details.hbs +106 -135
  2. package/addon/components/device/details.js +174 -1
  3. package/addon/components/device/panel-header.hbs +2 -2
  4. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.hbs +201 -0
  5. package/addon/components/layout/fleet-ops-sidebar/operations-monitor.js +654 -0
  6. package/addon/components/layout/fleet-ops-sidebar.hbs +19 -233
  7. package/addon/components/layout/fleet-ops-sidebar.js +364 -395
  8. package/addon/components/maintenance-schedule/form.js +1 -0
  9. package/addon/components/order/form/details.hbs +1 -43
  10. package/addon/components/order/form/details.js +18 -53
  11. package/addon/components/order/form/orchestrator-constraints.hbs +40 -0
  12. package/addon/components/order/form/orchestrator-constraints.js +61 -0
  13. package/addon/components/order/form/payload.js +50 -2
  14. package/addon/components/order/form/route.js +15 -0
  15. package/addon/components/order/form/service-rate.hbs +62 -11
  16. package/addon/components/order/form/service-rate.js +124 -2
  17. package/addon/components/order/form.hbs +2 -0
  18. package/addon/components/telematic/details.hbs +7 -1
  19. package/addon/components/telematic/form.js +16 -8
  20. package/addon/components/work-order/details.hbs +5 -1
  21. package/addon/components/work-order/form.hbs +26 -4
  22. package/addon/components/work-order/form.js +5 -1
  23. package/addon/controllers/connectivity/devices/index/details/events.js +244 -1
  24. package/addon/controllers/connectivity/devices/index/details/sensors.js +199 -0
  25. package/addon/controllers/connectivity/devices/index/details/vehicle.js +74 -0
  26. package/addon/controllers/connectivity/devices/index/details.js +12 -0
  27. package/addon/controllers/connectivity/devices/index.js +4 -1
  28. package/addon/controllers/connectivity/telematics/details/attachments.js +220 -54
  29. package/addon/controllers/connectivity/telematics/details/devices.js +90 -41
  30. package/addon/controllers/connectivity/telematics/details/events.js +39 -11
  31. package/addon/controllers/connectivity/telematics/details/sensors.js +39 -19
  32. package/addon/controllers/maintenance/index.js +100 -0
  33. package/addon/controllers/maintenance/work-orders/index.js +18 -1
  34. package/addon/controllers/management/fuel-transactions/index.js +1 -1
  35. package/addon/controllers/management/index.js +88 -0
  36. package/addon/controllers/settings/index.js +142 -0
  37. package/addon/extension.js +21 -0
  38. package/addon/routes/analytics/index.js +3 -0
  39. package/addon/routes/connectivity/devices/index/details/events.js +37 -1
  40. package/addon/routes/connectivity/devices/index/details/sensors.js +35 -0
  41. package/addon/routes/connectivity/devices/index/details/vehicle.js +7 -0
  42. package/addon/routes/connectivity/devices/index.js +3 -0
  43. package/addon/routes/connectivity/telematics/details/attachments.js +72 -9
  44. package/addon/routes/connectivity/telematics/details/devices.js +4 -0
  45. package/addon/routes/connectivity/telematics/details/sensors.js +1 -0
  46. package/addon/routes/connectivity/telematics/details.js +1 -1
  47. package/addon/routes/connectivity/telematics/edit.js +1 -1
  48. package/addon/routes/maintenance/index.js +3 -0
  49. package/addon/routes/maintenance/work-orders/index.js +3 -0
  50. package/addon/routes/settings/index.js +3 -0
  51. package/addon/routes.js +2 -0
  52. package/addon/services/order-creation.js +54 -1
  53. package/addon/services/order-validation.js +3 -2
  54. package/addon/services/service-rate-actions.js +1 -0
  55. package/addon/styles/fleetops-engine.css +1821 -6
  56. package/addon/templates/analytics/index.hbs +14 -0
  57. package/addon/templates/analytics/reports/index.hbs +4 -4
  58. package/addon/templates/connectivity/devices/index/details/events.hbs +85 -1
  59. package/addon/templates/connectivity/devices/index/details/sensors.hbs +73 -0
  60. package/addon/templates/connectivity/devices/index/details/vehicle.hbs +84 -0
  61. package/addon/templates/connectivity/telematics/details/attachments.hbs +198 -121
  62. package/addon/templates/connectivity/telematics/details/devices.hbs +4 -1
  63. package/addon/templates/connectivity/telematics/details/events.hbs +4 -1
  64. package/addon/templates/connectivity/telematics/details/sensors.hbs +3 -0
  65. package/addon/templates/maintenance/index.hbs +113 -0
  66. package/addon/templates/management/fuel-transactions/index/details.hbs +1 -1
  67. package/addon/templates/management/index.hbs +109 -73
  68. package/addon/templates/management/vehicles/index/edit.hbs +1 -1
  69. package/addon/templates/operations/orders/index.hbs +1 -1
  70. package/addon/templates/operations/service-rates/index.hbs +1 -1
  71. package/addon/templates/settings/index.hbs +83 -0
  72. package/addon/templates/settings/routing.hbs +2 -2
  73. package/addon/utils/fleet-ops-options.js +38 -0
  74. package/app/components/order/form/orchestrator-constraints.js +1 -0
  75. package/app/controllers/connectivity/devices/index/details/sensors.js +1 -0
  76. package/app/controllers/connectivity/devices/index/details/vehicle.js +1 -0
  77. package/app/controllers/maintenance/index.js +1 -0
  78. package/app/controllers/management/index.js +1 -0
  79. package/app/controllers/settings/index.js +1 -0
  80. package/app/routes/analytics/index.js +1 -0
  81. package/app/routes/connectivity/devices/index/details/sensors.js +1 -0
  82. package/app/routes/connectivity/devices/index/details/vehicle.js +1 -0
  83. package/app/routes/maintenance/index.js +1 -0
  84. package/app/routes/settings/index.js +1 -0
  85. package/app/templates/analytics/index.js +1 -0
  86. package/app/templates/connectivity/devices/index/details/sensors.js +1 -0
  87. package/app/templates/connectivity/devices/index/details/vehicle.js +1 -0
  88. package/app/templates/maintenance/index.js +1 -0
  89. package/app/templates/management/index.js +1 -0
  90. package/app/templates/settings/index.js +1 -0
  91. package/composer.json +1 -1
  92. package/extension.json +1 -1
  93. package/package.json +3 -3
  94. package/server/migrations/2025_08_28_054930_create_work_orders_table.php +1 -0
  95. package/server/migrations/2026_06_15_000001_normalize_device_attachable_vehicle_morph_types.php +30 -0
  96. package/server/migrations/2026_06_18_000001_add_category_to_work_orders_table.php +32 -0
  97. package/server/seeders/Testing/MaintenanceSeeder.php +5 -4
  98. package/server/src/Console/Commands/FixInvalidPolymorphicRelationTypeNamespaces.php +1 -0
  99. package/server/src/Console/Commands/ProcessMaintenanceTriggers.php +1 -0
  100. package/server/src/Console/Commands/SyncTelematics.php +89 -0
  101. package/server/src/Exceptions/TelematicProviderException.php +13 -2
  102. package/server/src/Http/Controllers/Api/v1/OrderController.php +6 -21
  103. package/server/src/Http/Controllers/Internal/v1/DeviceController.php +112 -11
  104. package/server/src/Http/Controllers/Internal/v1/DeviceEventController.php +16 -5
  105. package/server/src/Http/Controllers/Internal/v1/FleetController.php +9 -0
  106. package/server/src/Http/Controllers/Internal/v1/HubController.php +300 -0
  107. package/server/src/Http/Controllers/Internal/v1/LiveController.php +177 -0
  108. package/server/src/Http/Controllers/Internal/v1/MaintenanceScheduleController.php +1 -0
  109. package/server/src/Http/Controllers/Internal/v1/OrderController.php +4 -32
  110. package/server/src/Http/Controllers/Internal/v1/SearchController.php +506 -0
  111. package/server/src/Http/Controllers/Internal/v1/ServiceQuoteController.php +4 -4
  112. package/server/src/Http/Controllers/Internal/v1/TelematicController.php +1 -1
  113. package/server/src/Http/Controllers/Internal/v1/VehicleController.php +94 -10
  114. package/server/src/Http/Filter/DeviceEventFilter.php +44 -0
  115. package/server/src/Http/Filter/DeviceFilter.php +131 -0
  116. package/server/src/Http/Filter/DriverFilter.php +6 -0
  117. package/server/src/Http/Filter/SensorFilter.php +125 -0
  118. package/server/src/Http/Filter/VehicleFilter.php +19 -0
  119. package/server/src/Http/Requests/CreateDriverRequest.php +14 -13
  120. package/server/src/Http/Requests/Internal/CreateDriverRequest.php +1 -0
  121. package/server/src/Http/Resources/v1/Driver.php +11 -2
  122. package/server/src/Http/Resources/v1/WorkOrder.php +1 -0
  123. package/server/src/Jobs/FinalizeApiOrderCreation.php +47 -0
  124. package/server/src/Jobs/FinalizeInternalOrderCreation.php +35 -0
  125. package/server/src/Jobs/NotifyBulkAssignedDriver.php +47 -0
  126. package/server/src/Jobs/SyncTelematicDevicesJob.php +181 -66
  127. package/server/src/Models/Device.php +15 -1
  128. package/server/src/Models/DeviceEvent.php +1 -1
  129. package/server/src/Models/Driver.php +17 -1
  130. package/server/src/Models/Sensor.php +15 -2
  131. package/server/src/Models/Telematic.php +1 -1
  132. package/server/src/Models/Vehicle.php +5 -1
  133. package/server/src/Models/WorkOrder.php +5 -2
  134. package/server/src/Observers/DriverObserver.php +3 -3
  135. package/server/src/Observers/FleetObserver.php +23 -0
  136. package/server/src/Observers/VehicleObserver.php +3 -3
  137. package/server/src/Providers/FleetOpsServiceProvider.php +12 -0
  138. package/server/src/Support/LiveCacheService.php +1 -1
  139. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +188 -28
  140. package/server/src/Support/Telematics/Providers/SafeeProvider.php +44 -3
  141. package/server/src/Support/Telematics/TelematicService.php +330 -30
  142. package/server/src/routes.php +10 -0
  143. package/server/tests/DeviceFilterTest.php +36 -0
  144. package/server/tests/HubRoutesTest.php +62 -0
  145. package/server/tests/OperationsMonitorEndpointTest.php +38 -0
  146. package/server/tests/OrderQueuedClosureRegressionTest.php +36 -0
  147. package/server/tests/TelematicsHardeningTest.php +455 -3
  148. package/server/tests/VehicleDriverAssignmentTest.php +27 -0
  149. package/translations/ar-ae.yml +1 -1
  150. package/translations/en-us.yaml +1 -0
  151. package/translations/vi-vn.yaml +1 -1
@@ -2,8 +2,12 @@
2
2
 
3
3
  namespace Fleetbase\FleetOps\Support\Telematics\Providers;
4
4
 
5
+ use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
6
+ use Illuminate\Http\Client\ConnectionException;
7
+ use Illuminate\Http\Client\Response;
5
8
  use Illuminate\Support\Carbon;
6
9
  use Illuminate\Support\Facades\Http;
10
+ use Illuminate\Support\Facades\Log;
7
11
 
8
12
  /**
9
13
  * AFAQY telematics provider implementation.
@@ -13,20 +17,23 @@ use Illuminate\Support\Facades\Http;
13
17
  */
14
18
  class AfaqyProvider extends AbstractProvider
15
19
  {
16
- protected string $baseUrl = 'https://api.afaqy.sa';
17
- protected int $requestsPerMinute = 60;
20
+ protected string $baseUrl = 'https://api.afaqy.sa';
21
+ protected int $requestsPerMinute = 60;
22
+ protected int $dataTimeout = 120;
23
+ protected int $connectTimeout = 15;
24
+ protected int $connectionTestTimeout = 30;
25
+ protected int $connectionTestConnectTimeout = 10;
18
26
 
19
27
  protected function prepareAuthentication(): void
20
28
  {
21
29
  $this->baseUrl = rtrim($this->credentials['base_url'] ?? $this->baseUrl, '/');
22
- $token = $this->credentials['token'] ?? $this->authenticate();
30
+ $token = $this->canRefreshToken() ? $this->authenticate() : ($this->credentials['token'] ?? null);
23
31
 
24
- $this->credentials['token'] = $token;
25
- $this->headers = [
26
- 'Accept' => 'application/json',
27
- 'Content-Type' => 'application/json',
28
- 'Authorization' => 'Bearer ' . $token,
29
- ];
32
+ if (!$token) {
33
+ throw new \InvalidArgumentException('AFAQY username/password or token is required.');
34
+ }
35
+
36
+ $this->setToken($token);
30
37
  }
31
38
 
32
39
  public function testConnection(array $credentials): array
@@ -40,7 +47,7 @@ class AfaqyProvider extends AbstractProvider
40
47
  'projection' => ['_id', 'name', 'imei', 'last_update'],
41
48
  'filters' => new \stdClass(),
42
49
  ],
43
- ]);
50
+ ], true, $this->connectionTestTimeout, $this->connectionTestConnectTimeout);
44
51
 
45
52
  return [
46
53
  'success' => true,
@@ -54,20 +61,27 @@ class AfaqyProvider extends AbstractProvider
54
61
  return [
55
62
  'success' => false,
56
63
  'message' => $e->getMessage(),
57
- 'metadata' => [],
64
+ 'metadata' => method_exists($e, 'context') ? $e->context() : [],
58
65
  ];
59
66
  }
60
67
  }
61
68
 
62
69
  public function fetchDevices(array $options = []): array
63
70
  {
64
- $limit = (int) ($options['limit'] ?? 100);
65
- $offset = (int) ($options['cursor'] ?? 0);
71
+ $limit = max(1, min((int) ($options['limit'] ?? 500), 500));
72
+ $offset = (int) ($options['cursor'] ?? 0);
73
+ $filters = $options['filters'] ?? new \stdClass();
74
+
75
+ if (is_array($filters) && empty($filters)) {
76
+ $filters = new \stdClass();
77
+ }
66
78
 
67
79
  $response = $this->afaqyPost('/units/lists', [
68
80
  'data' => [
69
81
  'address' => $options['address'] ?? false,
70
- 'filters' => $options['filters'] ?? new \stdClass(),
82
+ 'filters' => $filters,
83
+ 'limit' => $limit,
84
+ 'offset' => $offset,
71
85
  'projection' => $options['projection'] ?? [
72
86
  '_id',
73
87
  'name',
@@ -82,19 +96,42 @@ class AfaqyProvider extends AbstractProvider
82
96
  'sensors_last_val',
83
97
  ],
84
98
  ],
85
- 'limit' => $limit,
86
- 'offset' => $offset,
99
+ ], true);
100
+
101
+ $devices = $response['data'] ?? [];
102
+ $pagination = $response['pagination'] ?? [];
103
+ $pageOffset = (int) ($pagination['offset'] ?? $offset);
104
+ $pageLimit = (int) ($pagination['limit'] ?? $limit);
105
+ $resultCount = (int) ($pagination['resultCount'] ?? count($devices));
106
+ $total = (int) ($pagination['filtersCount'] ?? $pagination['allCount'] ?? count($devices));
107
+ $advanceBy = $resultCount > 0 ? $resultCount : max($pageLimit, count($devices), 1);
108
+ $nextCursor = ($pageOffset + $advanceBy) < $total ? $pageOffset + $advanceBy : null;
109
+
110
+ Log::info('AFAQY units page fetched', [
111
+ 'telematic_uuid' => $this->telematic?->uuid,
112
+ 'requested_offset' => $offset,
113
+ 'requested_limit' => $limit,
114
+ 'provider_offset' => $pageOffset,
115
+ 'provider_limit' => $pageLimit,
116
+ 'provider_result_count' => $resultCount,
117
+ 'result_count' => count($devices),
118
+ 'all_count' => $pagination['allCount'] ?? null,
119
+ 'filters_count' => $pagination['filtersCount'] ?? null,
120
+ 'next_cursor' => $nextCursor,
121
+ 'has_more' => $nextCursor !== null,
87
122
  ]);
88
123
 
89
- $devices = $response['data'] ?? [];
90
- $pagination = $response['pagination'] ?? [];
91
- $total = (int) ($pagination['filtersCount'] ?? $pagination['allCount'] ?? count($devices));
92
- $nextCursor = ($offset + $limit) < $total ? $offset + $limit : null;
93
-
94
124
  return [
95
125
  'devices' => $devices,
96
126
  'next_cursor' => $nextCursor,
97
127
  'has_more' => $nextCursor !== null,
128
+ 'pagination' => [
129
+ 'allCount' => $pagination['allCount'] ?? null,
130
+ 'filtersCount' => $pagination['filtersCount'] ?? null,
131
+ 'resultCount' => $resultCount,
132
+ 'offset' => $pageOffset,
133
+ 'limit' => $pageLimit,
134
+ ],
98
135
  ];
99
136
  }
100
137
 
@@ -156,12 +193,14 @@ class AfaqyProvider extends AbstractProvider
156
193
  'event_type' => $payload['event'] ?? $payload['event_type'] ?? 'telemetry_update',
157
194
  'message' => $payload['message'] ?? $payload['event'] ?? null,
158
195
  'occurred_at' => $this->parseTimestamp($lastUpdate['dtt'] ?? $lastUpdate['dts'] ?? null),
196
+ 'online' => $payload['active'] ?? null,
159
197
  'location' => [
160
198
  'lat' => $lastUpdate['lat'] ?? data_get($lastUpdate, 'loc.coordinates.1'),
161
199
  'lng' => $lastUpdate['lng'] ?? data_get($lastUpdate, 'loc.coordinates.0'),
162
200
  ],
163
201
  'speed' => $lastUpdate['speed'] ?? $lastUpdate['spd'] ?? null,
164
202
  'heading' => $lastUpdate['angle'] ?? $lastUpdate['ang'] ?? null,
203
+ 'altitude' => $lastUpdate['alt'] ?? null,
165
204
  'odometer' => data_get($payload, 'counters.odometer'),
166
205
  'ignition' => $this->extractIgnition($payload),
167
206
  'fuel_level' => $this->extractFuelLevel($payload),
@@ -240,31 +279,152 @@ class AfaqyProvider extends AbstractProvider
240
279
  ]);
241
280
 
242
281
  if ($response->failed()) {
243
- throw new \RuntimeException('AFAQY authentication failed with status ' . $response->status());
282
+ throw new TelematicProviderException('AFAQY authentication failed with status ' . $response->status(), $this->providerErrorContext('/auth/login', $response, false));
244
283
  }
245
284
 
246
285
  $token = data_get($response->json(), 'data.token');
247
286
 
248
287
  if (!$token) {
249
- throw new \RuntimeException('AFAQY authentication did not return a token.');
288
+ throw new TelematicProviderException('AFAQY authentication did not return a token.', $this->providerErrorContext('/auth/login', $response, false));
250
289
  }
251
290
 
252
291
  return $token;
253
292
  }
254
293
 
255
- protected function afaqyPost(string $endpoint, array $payload = []): array
294
+ protected function afaqyPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, ?int $timeout = null, ?int $connectTimeout = null): array
256
295
  {
257
- $response = Http::withHeaders($this->headers)
258
- ->timeout(30)
259
- ->post($this->baseUrl . $endpoint, array_merge(['token' => $this->credentials['token']], $payload));
296
+ return $this->authenticatedPost($endpoint, $payload, $tokenInQuery, true, $timeout, $connectTimeout);
297
+ }
298
+
299
+ protected function authenticatedPost(string $endpoint, array $payload = [], bool $tokenInQuery = false, bool $allowRetry = true, ?int $timeout = null, ?int $connectTimeout = null): array
300
+ {
301
+ [$url, $body] = $this->buildAuthenticatedRequest($endpoint, $payload, $tokenInQuery);
302
+
303
+ $startedAt = microtime(true);
304
+ $timeout ??= $this->dataTimeout;
305
+ $connectTimeout ??= $this->connectTimeout;
306
+
307
+ try {
308
+ $response = Http::withHeaders($this->headers)
309
+ ->timeout($timeout)
310
+ ->connectTimeout($connectTimeout)
311
+ ->post($url, $body);
312
+ } catch (ConnectionException $e) {
313
+ throw new TelematicProviderException('AFAQY API request timed out while waiting for provider response.', $this->transportErrorContext($endpoint, $payload, $e, !$allowRetry, $startedAt, $timeout, $connectTimeout), previous: $e);
314
+ }
315
+
316
+ if ($this->isTokenRejected($response)) {
317
+ if (!$allowRetry || !$this->canRefreshToken()) {
318
+ $message = $this->canRefreshToken()
319
+ ? 'AFAQY token rejected after refresh with status ' . $response->status()
320
+ : 'AFAQY token rejected and username/password credentials are required to refresh it.';
321
+
322
+ throw new TelematicProviderException($message, $this->providerErrorContext($endpoint, $response, !$allowRetry));
323
+ }
324
+
325
+ Log::warning('AFAQY token rejected; refreshing token and retrying request', $this->providerErrorContext($endpoint, $response, true));
326
+
327
+ $this->refreshToken();
328
+
329
+ return $this->authenticatedPost($endpoint, $payload, $tokenInQuery, false, $timeout, $connectTimeout);
330
+ }
260
331
 
261
332
  if ($response->failed()) {
262
- throw new \RuntimeException('AFAQY API request failed with status ' . $response->status());
333
+ throw new TelematicProviderException('AFAQY API request failed with status ' . $response->status(), $this->providerErrorContext($endpoint, $response, !$allowRetry));
263
334
  }
264
335
 
265
336
  return $response->json() ?? [];
266
337
  }
267
338
 
339
+ protected function buildAuthenticatedRequest(string $endpoint, array $payload = [], bool $tokenInQuery = false): array
340
+ {
341
+ $url = $this->baseUrl . $endpoint;
342
+ $body = $tokenInQuery ? $payload : array_merge(['token' => $this->credentials['token']], $payload);
343
+
344
+ if ($tokenInQuery) {
345
+ $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query(['token' => $this->credentials['token']]);
346
+ }
347
+
348
+ return [$url, $body];
349
+ }
350
+
351
+ protected function setToken(string $token): void
352
+ {
353
+ $this->credentials['token'] = $token;
354
+ $this->headers = [
355
+ 'Accept' => 'application/json',
356
+ 'Content-Type' => 'application/json',
357
+ 'Authorization' => 'Bearer ' . $token,
358
+ ];
359
+ }
360
+
361
+ protected function refreshToken(): void
362
+ {
363
+ unset($this->credentials['token']);
364
+
365
+ $this->setToken($this->authenticate());
366
+ }
367
+
368
+ protected function canRefreshToken(): bool
369
+ {
370
+ return !empty($this->credentials['username']) && !empty($this->credentials['password']);
371
+ }
372
+
373
+ protected function isTokenRejected(Response $response): bool
374
+ {
375
+ return in_array($response->status(), [401, 403], true);
376
+ }
377
+
378
+ protected function providerErrorContext(string $endpoint, Response $response, bool $retryAttempted): array
379
+ {
380
+ $json = $response->json() ?? [];
381
+ $json = is_array($json) ? $json : [];
382
+
383
+ return array_filter([
384
+ 'provider' => 'afaqy',
385
+ 'endpoint' => $endpoint,
386
+ 'status_code' => $response->status(),
387
+ 'provider_code' => data_get($json, 'code') ?? data_get($json, 'error.code') ?? data_get($json, 'error_code'),
388
+ 'provider_message' => $this->providerErrorMessage($json),
389
+ 'retry_attempted' => $retryAttempted,
390
+ ], fn ($value) => $value !== null);
391
+ }
392
+
393
+ protected function providerErrorMessage(array $json): ?string
394
+ {
395
+ $message = data_get($json, 'message')
396
+ ?? data_get($json, 'error.message')
397
+ ?? data_get($json, 'error_description')
398
+ ?? data_get($json, 'error');
399
+
400
+ return is_scalar($message) ? (string) $message : null;
401
+ }
402
+
403
+ protected function transportErrorContext(string $endpoint, array $payload, ConnectionException $e, bool $retryAttempted, float $startedAt, int $timeout, int $connectTimeout): array
404
+ {
405
+ return array_filter([
406
+ 'provider' => 'afaqy',
407
+ 'endpoint' => $endpoint,
408
+ 'requested_limit' => data_get($payload, 'data.limit'),
409
+ 'requested_offset' => data_get($payload, 'data.offset'),
410
+ 'timeout' => $timeout,
411
+ 'connect_timeout' => $connectTimeout,
412
+ 'elapsed_ms' => (int) ((microtime(true) - $startedAt) * 1000),
413
+ 'bytes_received' => $this->extractBytesReceived($e->getMessage()),
414
+ 'retry_attempted' => $retryAttempted,
415
+ 'transport_error' => 'connection_exception',
416
+ ], fn ($value) => $value !== null);
417
+ }
418
+
419
+ protected function extractBytesReceived(string $message): ?int
420
+ {
421
+ if (preg_match('/with\s+(\d+)\s+bytes\s+received/i', $message, $matches)) {
422
+ return (int) $matches[1];
423
+ }
424
+
425
+ return null;
426
+ }
427
+
268
428
  protected function compactLastUpdate(array $lastUpdate): array
269
429
  {
270
430
  $params = $lastUpdate['params'] ?? $lastUpdate['prms'] ?? [];
@@ -16,10 +16,11 @@ class SafeeProvider extends AbstractProvider
16
16
  protected string $baseUrl = 'https://api.safee.com';
17
17
  protected int $requestsPerMinute = 3000;
18
18
  protected ?string $accessToken = null;
19
+ protected array $authContext = [];
19
20
 
20
21
  protected function prepareAuthentication(): void
21
22
  {
22
- $this->baseUrl = rtrim($this->credentials['api_base_url'] ?? $this->credentials['server_uri'] ?? $this->baseUrl, '/');
23
+ $this->baseUrl = $this->resolveBaseUrl();
23
24
  $this->accessToken = $this->credentials['access_token'] ?? $this->authenticate();
24
25
  $scheme = $this->credentials['authorization_scheme'] ?? 'Bearer';
25
26
 
@@ -45,13 +46,14 @@ class SafeeProvider extends AbstractProvider
45
46
  'metadata' => [
46
47
  'status' => $response['status'] ?? null,
47
48
  'time' => $response['time'] ?? null,
49
+ ...$this->safeDiagnosticMetadata(),
48
50
  ],
49
51
  ];
50
52
  } catch (\Throwable $e) {
51
53
  return [
52
54
  'success' => false,
53
55
  'message' => $e->getMessage(),
54
- 'metadata' => [],
56
+ 'metadata' => $this->safeDiagnosticMetadata(),
55
57
  ];
56
58
  }
57
59
  }
@@ -219,7 +221,8 @@ class SafeeProvider extends AbstractProvider
219
221
  }
220
222
  }
221
223
 
222
- $tokenUrl = $this->baseUrl . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token';
224
+ $tokenUrl = $this->baseUrl . '/auth/realms/' . $this->credentials['realm_id'] . '/protocol/openid-connect/token';
225
+ $this->authContext = $this->buildAuthContext($tokenUrl);
223
226
 
224
227
  $response = Http::asForm()
225
228
  ->acceptJson()
@@ -245,6 +248,44 @@ class SafeeProvider extends AbstractProvider
245
248
  return $token;
246
249
  }
247
250
 
251
+ protected function resolveBaseUrl(): string
252
+ {
253
+ $baseUrl = $this->filledCredential('api_base_url') ?? $this->filledCredential('server_uri') ?? $this->baseUrl;
254
+
255
+ return rtrim($baseUrl, '/');
256
+ }
257
+
258
+ protected function filledCredential(string $key): ?string
259
+ {
260
+ $value = $this->credentials[$key] ?? null;
261
+
262
+ if (!is_string($value)) {
263
+ return null;
264
+ }
265
+
266
+ $value = trim($value);
267
+
268
+ return $value === '' ? null : $value;
269
+ }
270
+
271
+ protected function buildAuthContext(string $tokenUrl): array
272
+ {
273
+ $parts = parse_url($tokenUrl) ?: [];
274
+ $scheme = $parts['scheme'] ?? null;
275
+ $host = $parts['host'] ?? null;
276
+
277
+ return [
278
+ 'auth_host' => $host ? trim(($scheme ? $scheme . '://' : '') . $host, '/') : null,
279
+ 'auth_path' => $parts['path'] ?? null,
280
+ 'realm_id' => $this->credentials['realm_id'] ?? null,
281
+ ];
282
+ }
283
+
284
+ protected function safeDiagnosticMetadata(): array
285
+ {
286
+ return array_filter($this->authContext, fn ($value) => $value !== null && $value !== '');
287
+ }
288
+
248
289
  protected function safeeGet(string $endpoint): array
249
290
  {
250
291
  $response = Http::withHeaders($this->headers)