@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
@@ -0,0 +1,210 @@
1
+ <?php
2
+
3
+ namespace Fleetbase\FleetOps\Support\Telematics\Safee;
4
+
5
+ use Fleetbase\FleetOps\Exceptions\TelematicProviderException;
6
+ use Fleetbase\FleetOps\Exceptions\TelematicRateLimitExceededException;
7
+ use Illuminate\Contracts\Encryption\DecryptException;
8
+ use Illuminate\Http\Client\ConnectionException;
9
+ use Illuminate\Http\Client\Response;
10
+ use Illuminate\Support\Facades\Cache;
11
+ use Illuminate\Support\Facades\Crypt;
12
+ use Illuminate\Support\Facades\Http;
13
+
14
+ /** Safee's documented OAuth and per-user request budget, shared across workers. */
15
+ class Transport
16
+ {
17
+ protected ?string $token = null;
18
+ protected ?float $expiresAt = null;
19
+
20
+ public function __construct(protected string $baseUrl, protected array $credentials)
21
+ {
22
+ $this->token = $credentials['access_token'] ?? null;
23
+ }
24
+
25
+ public function fingerprint(): string
26
+ {
27
+ $credentials = $this->credentials;
28
+ ksort($credentials);
29
+
30
+ return hash('sha256', $this->baseUrl . '|' . json_encode($credentials, JSON_THROW_ON_ERROR));
31
+ }
32
+
33
+ public function accountKey(): string
34
+ {
35
+ return hash('sha256', implode('|', [$this->baseUrl, $this->credentials['realm_id'] ?? '', $this->credentials['username'] ?? $this->credentials['access_token'] ?? '']));
36
+ }
37
+
38
+ public function request(string $method, string $endpoint, array|\stdClass $payload, float $deadline, int $timeout, int $connectTimeout): array
39
+ {
40
+ $refreshed = false;
41
+ while (true) {
42
+ if (!$this->token || ($this->expiresAt !== null && $this->expiresAt <= $this->wallTime() + 5)) {
43
+ $this->token = $this->cachedToken($deadline, $timeout, $connectTimeout);
44
+ }
45
+ $this->reserveRequest($deadline);
46
+ try {
47
+ $request = Http::withHeaders([
48
+ 'Accept' => 'application/json', 'Content-Type' => 'application/json',
49
+ 'Accept-Language' => $this->credentials['language'] ?? 'en',
50
+ 'Authorization' => trim(($this->credentials['authorization_scheme'] ?? 'Bearer') . ' ' . $this->token),
51
+ ])->timeout($this->remaining($deadline, $timeout))
52
+ ->connectTimeout($this->remaining($deadline, $connectTimeout));
53
+ $response = $method === 'GET' ? $request->get($this->baseUrl . $endpoint) : $request->post($this->baseUrl . $endpoint, $payload);
54
+ } catch (ConnectionException $e) {
55
+ throw new TelematicProviderException('Safee request timed out or could not connect.', ['endpoint' => $endpoint], previous: $e);
56
+ }
57
+ $this->checkThrottle($response);
58
+ // A rejected token is refreshed once; a second rejection fails as an unsuccessful response.
59
+ if ($response->status() === 401 && !$refreshed && $this->canAuthenticate()) {
60
+ $refreshed = true;
61
+ $this->token = $this->cachedToken($deadline, $timeout, $connectTimeout, $this->token);
62
+ continue;
63
+ }
64
+ if ($response->failed()) {
65
+ throw new TelematicProviderException('Safee API request failed with status ' . $response->status(), ['endpoint' => $endpoint, 'status' => $response->status()]);
66
+ }
67
+ $json = $response->json();
68
+ if (!is_array($json) || !array_key_exists('code', $json) || !is_numeric($json['code']) || (float) $json['code'] !== 0.0) {
69
+ throw new TelematicProviderException('Safee returned an invalid or unsuccessful response.', ['endpoint' => $endpoint]);
70
+ }
71
+
72
+ return $json;
73
+ }
74
+ }
75
+
76
+ public function authenticate(float $deadline, int $timeout = 30, int $connectTimeout = 5): string
77
+ {
78
+ return $this->cachedToken($deadline, $timeout, $connectTimeout);
79
+ }
80
+
81
+ protected function canAuthenticate(): bool
82
+ {
83
+ foreach (['realm_id', 'client_id', 'client_secret', 'username', 'password'] as $field) {
84
+ if (empty($this->credentials[$field])) {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ return true;
90
+ }
91
+
92
+ protected function cachedToken(float $deadline, int $timeout, int $connectTimeout, ?string $rejected = null): string
93
+ {
94
+ if (!$this->canAuthenticate()) {
95
+ throw new \InvalidArgumentException('Safee realm, client and user credentials are required to authenticate.');
96
+ }
97
+ $key = 'safee:token:' . $this->fingerprint();
98
+
99
+ return Cache::lock($key . ':lock', 60)->block($this->remaining($deadline, 2), function () use ($key, $deadline, $timeout, $connectTimeout, $rejected) {
100
+ $cached = null;
101
+ if ($encrypted = Cache::get($key)) {
102
+ try {
103
+ $cached = json_decode(Crypt::decryptString($encrypted), true, 512, JSON_THROW_ON_ERROR);
104
+ } catch (DecryptException|\JsonException) {
105
+ // Only the disposable token cache is recoverable this way.
106
+ Cache::forget($key);
107
+ }
108
+ }
109
+ if (is_array($cached) && is_string($cached['access_token'] ?? null) && $cached['access_token'] !== $rejected && ($cached['expires_at'] ?? 0) > $this->wallTime() + 5) {
110
+ $this->expiresAt = (float) $cached['expires_at'];
111
+
112
+ return $cached['access_token'];
113
+ }
114
+ $refresh = is_array($cached) && is_string($cached['refresh_token'] ?? null) ? $cached['refresh_token'] : null;
115
+ $tokens = $this->tokenRequest($deadline, $timeout, $connectTimeout, $refresh);
116
+ $lifetime = is_numeric($tokens['expires_in'] ?? null) ? max(0, (int) $tokens['expires_in']) : 0;
117
+ $this->expiresAt = $lifetime > 0 ? $this->wallTime() + $lifetime : null;
118
+ if ($lifetime > 0) {
119
+ $tokens['expires_at'] = $this->expiresAt;
120
+ Cache::put($key, Crypt::encryptString(json_encode($tokens, JSON_THROW_ON_ERROR)), max($lifetime, min(86400, (int) ($tokens['refresh_expires_in'] ?? $lifetime))));
121
+ } else {
122
+ // An undocumented expiry must not create a long-lived cached token.
123
+ Cache::forget($key);
124
+ }
125
+
126
+ return $tokens['access_token'];
127
+ });
128
+ }
129
+
130
+ protected function tokenRequest(float $deadline, int $timeout, int $connectTimeout, ?string $refresh = null): array
131
+ {
132
+ $body = ['grant_type' => $refresh ? 'refresh_token' : 'password', 'client_id' => $this->credentials['client_id'], 'client_secret' => $this->credentials['client_secret']];
133
+ $body += $refresh ? ['refresh_token' => $refresh] : ['username' => $this->credentials['username'], 'password' => $this->credentials['password']];
134
+ $this->reserveRequest($deadline);
135
+ try {
136
+ $response = Http::asForm()->acceptJson()->timeout($this->remaining($deadline, $timeout))
137
+ ->connectTimeout($this->remaining($deadline, $connectTimeout))
138
+ ->post($this->baseUrl . '/auth/realms/' . rawurlencode($this->credentials['realm_id']) . '/protocol/openid-connect/token', $body);
139
+ } catch (ConnectionException $e) {
140
+ throw new TelematicProviderException('Safee authentication timed out or could not connect.', previous: $e);
141
+ }
142
+ $this->checkThrottle($response);
143
+ // Expired/revoked refresh tokens may be exchanged for a new password grant once.
144
+ if ($refresh && in_array($response->status(), [400, 401], true)) {
145
+ return $this->tokenRequest($deadline, $timeout, $connectTimeout);
146
+ }
147
+ if ($response->failed()) {
148
+ throw new TelematicProviderException('Safee authentication failed with status ' . $response->status());
149
+ }
150
+ $tokens = $response->json();
151
+ if (!is_array($tokens) || !is_string($tokens['access_token'] ?? null) || $tokens['access_token'] === '') {
152
+ throw new TelematicProviderException('Safee authentication did not return an access token.');
153
+ }
154
+
155
+ return array_intersect_key($tokens, array_flip(['access_token', 'refresh_token', 'expires_in', 'refresh_expires_in']));
156
+ }
157
+
158
+ protected function reserveRequest(float $deadline): void
159
+ {
160
+ $key = 'safee:rate:' . $this->accountKey();
161
+ Cache::lock($key . ':lock', 5)->block($this->remaining($deadline, 2), function () use ($key, $deadline) {
162
+ $this->remaining($deadline, 1);
163
+ $now = $this->wallTime();
164
+ $until = (float) Cache::get($key . ':blocked', 0);
165
+ $requests = array_values(array_filter(Cache::get($key, []), fn ($at) => $at > $now - 1));
166
+ if ($until > $now || count($requests) >= 50) {
167
+ throw new TelematicRateLimitExceededException('Safee request budget exhausted.', ['retry_after' => max(1, (int) ceil(max($until, ($requests[0] ?? $now) + 1) - $now))]);
168
+ }
169
+ $requests[] = $now;
170
+ Cache::put($key, $requests, 2);
171
+ });
172
+ }
173
+
174
+ protected function checkThrottle(Response $response): void
175
+ {
176
+ if ($response->status() !== 429) {
177
+ return;
178
+ }
179
+ $header = $response->header('Retry-After');
180
+ $delay = is_numeric($header) ? (int) ceil((float) $header) : max(1, (strtotime($header ?: '') ?: time() + 1) - time());
181
+ $delay = max(1, min($delay, 3600));
182
+ $key = 'safee:rate:' . $this->accountKey();
183
+ Cache::lock($key . ':lock', 5)->block(1, function () use ($key, $delay) {
184
+ $until = max((float) Cache::get($key . ':blocked', 0), $this->wallTime() + $delay);
185
+ Cache::put($key . ':blocked', $until, (int) ceil($until - $this->wallTime()));
186
+ });
187
+
188
+ throw new TelematicRateLimitExceededException('Safee rate limited the request.', ['retry_after' => $delay]);
189
+ }
190
+
191
+ protected function remaining(float $deadline, int $maximum): int
192
+ {
193
+ $remaining = (int) floor($deadline - $this->time());
194
+ if ($remaining < 1) {
195
+ throw new TelematicProviderException('Safee request time budget exhausted before another operation could start.');
196
+ }
197
+
198
+ return min(max(1, $maximum), $remaining);
199
+ }
200
+
201
+ protected function time(): float
202
+ {
203
+ return hrtime(true) / 1_000_000_000;
204
+ }
205
+
206
+ protected function wallTime(): float
207
+ {
208
+ return microtime(true);
209
+ }
210
+ }
@@ -152,6 +152,11 @@ class TelematicService
152
152
  */
153
153
  public function discoverDevices(Telematic $telematic, array $options = []): string
154
154
  {
155
+ $provider = $this->registry->resolve($telematic->provider);
156
+ if ($provider instanceof \Fleetbase\FleetOps\Contracts\TelemetryProviderInterface
157
+ && (Telemetry\Configuration::options($provider)['manual_batch_sync'] ?? false)) {
158
+ return $this->queueTelemetrySync($telematic, $options);
159
+ }
155
160
  $jobId = (string) Str::uuid();
156
161
  dispatch(new SyncTelematicDevicesJob($telematic, $options, $jobId));
157
162
 
@@ -167,6 +172,43 @@ class TelematicService
167
172
  return $jobId;
168
173
  }
169
174
 
175
+ /** Queue current telemetry through bounded, durable batches for opted-in providers. */
176
+ public function queueTelemetrySync(Telematic $telematic, array $options = [], ?string $jobId = null): string
177
+ {
178
+ $settings = Telemetry\Configuration::options($this->registry->resolve($telematic->provider));
179
+ if (!Telemetry\Inbox::enabled($telematic) || !($settings['polling_enabled'] ?? false)) {
180
+ throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is disabled for this connection.']]);
181
+ }
182
+ $jobId ??= (string) Str::uuid();
183
+
184
+ return \Illuminate\Support\Facades\DB::transaction(function () use ($telematic, $options, $settings, $jobId) {
185
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $telematic->uuid)->lockForUpdate()->firstOrFail();
186
+ if (!Telemetry\Inbox::enabled($connection)) {
187
+ throw ValidationException::withMessages(['telematic' => ['Telemetry synchronization is disabled for this connection.']]);
188
+ }
189
+ $job = (new \Fleetbase\FleetOps\Jobs\PollTelematicTelemetry($connection->uuid, $jobId, $options))
190
+ ->onQueue($settings['poll_queue'] ?? 'default');
191
+ $queued = Telemetry\Queue::dispatch($job);
192
+ if (!$queued) {
193
+ $existing = data_get($connection->meta, 'last_sync_job_id');
194
+ if ($existing && in_array(data_get($connection->meta, 'last_sync_result'), Telemetry\Inbox::PENDING_REQUEST_RESULTS, true)) {
195
+ return $existing;
196
+ }
197
+ // A scheduled sweep holds this connection's poll lease. Record the request so
198
+ // the next sweep adopts and completes it, rather than rejecting the user.
199
+ }
200
+ $connection->status = 'synchronizing';
201
+ $connection->meta = array_merge($connection->meta ?? [], [
202
+ 'last_sync_job_id' => $jobId, 'last_sync_run_uuid' => null, 'last_sync_run_job_id' => null,
203
+ 'last_sync_started_at' => now()->toDateTimeString(), 'last_sync_result' => 'queued',
204
+ 'last_sync_phase' => $queued ? 'queued' : 'waiting_for_sweep', 'last_sync_error' => null, 'last_sync_failed_reason' => null,
205
+ ]);
206
+ $connection->save();
207
+
208
+ return $jobId;
209
+ });
210
+ }
211
+
170
212
  /**
171
213
  * Link a device to a telematic.
172
214
  */
@@ -511,7 +553,8 @@ class TelematicService
511
553
  $this->setDeviceAttributeIfPresent($device, 'model', $payload['model'] ?? $payload['device_model'] ?? null);
512
554
  $this->setDeviceAttributeIfPresent($device, 'provider', $payload['provider'] ?? $payload['device_provider'] ?? $telematic?->provider);
513
555
  $this->setDeviceAttributeIfPresent($device, 'type', $payload['type'] ?? null);
514
- $this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? $externalId);
556
+ // Partial messages must not replace an existing identity with the external ID fallback.
557
+ $this->setDeviceAttributeIfPresent($device, 'internal_id', $payload['internal_id'] ?? (filled($device->internal_id) ? null : $externalId));
515
558
  $this->setDeviceAttributeIfPresent($device, 'imei', $payload['imei'] ?? null);
516
559
  $this->setDeviceAttributeIfPresent($device, 'imsi', $payload['imsi'] ?? null);
517
560
  $this->setDeviceAttributeIfPresent($device, 'serial_number', $payload['serial_number'] ?? null);
@@ -762,6 +805,7 @@ class TelematicService
762
805
 
763
806
  protected function broadcastTelemetry(object $event, bool $afterCommit): void
764
807
  {
808
+ $event = Telemetry\Configuration::withBroadcastQueue($event);
765
809
  if ($afterCommit) {
766
810
  \Illuminate\Support\Facades\DB::afterCommit(fn () => broadcast($event));
767
811
  } else {
@@ -23,6 +23,22 @@ class Configuration
23
23
  return array_replace(config('telematics.telemetry', []), $provider->telemetryOptions());
24
24
  }
25
25
 
26
+ /** Queue for connection-level jobs that predate durable polling (discovery and connection tests). */
27
+ public static function pollQueue(): string
28
+ {
29
+ return config('telematics.telemetry.poll_queue') ?: 'default';
30
+ }
31
+
32
+ /** Route a telemetry broadcast to the configured queue without changing other callers of the event. */
33
+ public static function withBroadcastQueue(object $event): object
34
+ {
35
+ if ($queue = config('telematics.telemetry.broadcast_queue')) {
36
+ $event->broadcastQueue = $queue;
37
+ }
38
+
39
+ return $event;
40
+ }
41
+
26
42
  public static function forConnection(Telematic $connection): array
27
43
  {
28
44
  return self::options(self::provider($connection));
@@ -10,6 +10,9 @@ use Illuminate\Support\Str;
10
10
 
11
11
  class Inbox
12
12
  {
13
+ /** Manual request states that a sweep may still finalize. */
14
+ public const PENDING_REQUEST_RESULTS = ['queued', 'running', 'retrying'];
15
+
13
16
  public function accept(Telematic $telematic, array $payload, string $source, ?string $run = null, ?string $receivedAt = null): string
14
17
  {
15
18
  $json = json_encode($payload, JSON_THROW_ON_ERROR);
@@ -51,10 +54,53 @@ class Inbox
51
54
  if ((clone $items)->whereIn('status', ['pending', 'processing', 'retry'])->exists()) {
52
55
  return;
53
56
  }
54
- $failed = (clone $items)->sum('failed');
57
+ $failed = (clone $items)->sum('failed');
58
+ $applied = (clone $items)->sum('applied');
59
+ $status = $run->status === 'incomplete' ? 'incomplete' : ($failed ? 'partial' : 'completed');
55
60
  DB::table('telematic_sync_runs')->where('uuid', $id)->update([
56
- 'status' => $run->status === 'incomplete' ? 'incomplete' : ($failed ? 'partial' : 'completed'),
57
- 'applied' => (clone $items)->sum('applied'), 'failed' => $failed, 'updated_at' => now(),
61
+ 'status' => $status,
62
+ 'applied' => $applied, 'failed' => $failed, 'updated_at' => now(),
63
+ ]);
64
+ // An incomplete fetch can still have accepted batches, but the poll
65
+ // job owns its retry/failure lifecycle. Draining them must not end the
66
+ // manual request while its next fetch attempt is still queued.
67
+ if ($status === 'incomplete') {
68
+ return;
69
+ }
70
+ $connection = Telematic::withoutGlobalScopes()->where('uuid', $run->telematic_uuid)->lockForUpdate()->first();
71
+ if (!$connection || !self::enabled($connection)) {
72
+ return;
73
+ }
74
+ $meta = $connection->meta ?? [];
75
+ $result = [
76
+ 'last_sync_result' => $status === 'completed' ? 'success' : $status,
77
+ 'last_sync_phase' => $status, 'last_sync_progress_at' => now()->toDateTimeString(),
78
+ 'last_sync_fetched_total' => (int) $run->units, 'last_sync_page_count' => (int) $run->pages,
79
+ 'last_sync_total' => (int) $applied, 'last_sync_linked_total' => (int) $applied,
80
+ 'last_sync_failed_total' => (int) $failed,
81
+ 'last_sync_error' => $status === 'completed' ? null : ($run->error ?? 'Some units could not be applied; inspect telemetry diagnostics.'),
82
+ $status === 'completed' ? 'last_sync_completed_at' : 'last_sync_failed_at' => now()->toDateTimeString(),
83
+ ];
84
+ // A previous sweep may finish after another manual request was queued.
85
+ // Only the run explicitly associated with that request may finalize it.
86
+ if (data_get($meta, 'last_sync_run_uuid') === $id && data_get($meta, 'last_sync_run_job_id')
87
+ && data_get($meta, 'last_sync_run_job_id') === data_get($meta, 'last_sync_job_id')) {
88
+ $connection->status = $status === 'completed' ? 'active' : 'error';
89
+ $connection->meta = array_merge($meta, $result);
90
+ $connection->save();
91
+
92
+ return;
93
+ }
94
+ // A completed scheduled sweep proves the connection is healthy, so it replaces a
95
+ // stale failure. It never finalizes or overrides a manual request still in flight,
96
+ // and a partial sweep leaves the last reported state alone.
97
+ if ($status !== 'completed' || in_array(data_get($meta, 'last_sync_result'), self::PENDING_REQUEST_RESULTS, true)) {
98
+ return;
99
+ }
100
+ // Written without model events: sweeps run every minute and the model logs every save.
101
+ DB::table('telematics')->where('uuid', $connection->uuid)->update([
102
+ 'status' => in_array($connection->status, ['error', 'synchronizing'], true) ? 'active' : $connection->status,
103
+ 'meta' => json_encode(array_merge($meta, $result), JSON_THROW_ON_ERROR),
58
104
  ]);
59
105
  });
60
106
  }
@@ -103,7 +103,7 @@ class Ingestor
103
103
  }
104
104
  if ($newer || $contactChanged) {
105
105
  $snapshot = $device->fresh() ?? $device;
106
- DB::afterCommit(fn () => broadcast(new DeviceTelemetryUpdated($snapshot)));
106
+ DB::afterCommit(fn () => broadcast(Configuration::withBroadcastQueue(new DeviceTelemetryUpdated($snapshot))));
107
107
  }
108
108
 
109
109
  return ['device' => $device, 'event' => $stored, 'events' => $stored ? [$stored] : [], 'sensors' => $sensors, 'duplicate' => $duplicate, 'invalid_position' => !$valid];