@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.
Files changed (47) hide show
  1. package/RELEASE.md +15 -8
  2. package/addon/components/telematic/details.hbs +1 -1
  3. package/addon/components/telematic/telemetry-status.hbs +93 -34
  4. package/addon/components/widget/radar.hbs +1 -1
  5. package/addon/components/widget/radar.js +7 -0
  6. package/composer.json +2 -1
  7. package/docs/AFAQY.md +71 -17
  8. package/docs/SAFEE.md +125 -0
  9. package/docs/TELEMATICS_QUEUES.md +179 -0
  10. package/extension.json +1 -1
  11. package/package.json +1 -1
  12. package/server/config/safee.php +10 -0
  13. package/server/config/telematics.php +8 -1
  14. package/server/config/telemetry.php +4 -1
  15. package/server/migrations/2026_09_17_000001_add_device_event_uuid_lookup_index.php +90 -0
  16. package/server/src/Console/Commands/SyncTelematics.php +11 -5
  17. package/server/src/Events/DeviceTelemetryUpdated.php +2 -1
  18. package/server/src/Events/TrailerLocationChanged.php +2 -0
  19. package/server/src/Events/VehicleLocationChanged.php +5 -0
  20. package/server/src/Jobs/PollTelematicTelemetry.php +127 -12
  21. package/server/src/Jobs/ProcessTelematicDelivery.php +64 -10
  22. package/server/src/Jobs/SyncTelematicDevicesJob.php +77 -1
  23. package/server/src/Jobs/TestTelematicConnectionJob.php +1 -0
  24. package/server/src/Providers/FleetOpsServiceProvider.php +5 -2
  25. package/server/src/Support/Telematics/Providers/AfaqyProvider.php +65 -10
  26. package/server/src/Support/Telematics/Providers/SafeeProvider.php +213 -139
  27. package/server/src/Support/Telematics/Safee/Transport.php +210 -0
  28. package/server/src/Support/Telematics/TelematicService.php +43 -1
  29. package/server/src/Support/Telematics/Telemetry/Configuration.php +16 -0
  30. package/server/src/Support/Telematics/Telemetry/Inbox.php +31 -3
  31. package/server/src/Support/Telematics/Telemetry/Ingestor.php +1 -1
  32. package/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php +147 -58
  33. package/server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php +106 -0
  34. package/server/tests/Feature/Http/ManualTelemetryPollingTest.php +461 -0
  35. package/server/tests/Feature/Http/SafeeRealtimeIngestionTest.php +208 -0
  36. package/server/tests/Feature/Http/TelematicDeliveryCheckpointTest.php +198 -0
  37. package/server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php +166 -0
  38. package/server/tests/ProviderContractsTest.php +50 -1
  39. package/server/tests/Support/AfaqyTestCrypto.php +6 -23
  40. package/server/tests/SupportJobAndAiCoverageTest.php +102 -5
  41. package/server/tests/TelematicsHardeningTest.php +18 -13
  42. package/server/tests/Unit/Console/SyncTelematicsCommandTest.php +140 -0
  43. package/server/tests/Unit/Support/Telematics/Providers/AfaqyProviderTransportTest.php +132 -0
  44. package/server/tests/Unit/Support/Telematics/Providers/ProviderTransportSeamsTest.php +1 -1
  45. package/server/tests/Unit/Support/Telematics/Providers/SafeeLiveContractTest.php +335 -0
  46. package/server/tests/Unit/Support/Telematics/Providers/SafeeProviderTest.php +17 -27
  47. package/server/tests/Unit/Support/Telematics/TelematicsQueueRoutingTest.php +36 -0
package/RELEASE.md CHANGED
@@ -1,20 +1,27 @@
1
- > v0.6.66 ~ "Inspections"
1
+ > v0.6.67 ~ "Telematics reliability"
2
2
 
3
3
  ---
4
4
  ## What's New
5
- - **Inspection forms are built from typed fields.** A form is groups of fields laid out on a grid: pass/fail checks with severity and on-fail settings, text, numbers, selections, dates, photo uploads and signatures. Forms are built and published from a form builder in the console.
6
- - **One inspection sheet everywhere.** The same sheet is used to fill in an inspection in the console, to read one back, and on a public link. A failed check opens its severity, unsafe flag, comment and photos in place, and a defects tray summarises what needs attention. It works on phones and tablets.
7
- - **Drivers file inspections through the API.** The `v1` inspection endpoints list published forms, file an inspection against one, and read back submissions and a vehicle's history, for the Navigator app and other integrations.
8
- - **Inspection links for anyone in the organisation.** A link can be assigned to any user, protected by a six-digit PIN, and emailed or texted to them. A failed inspection can raise an issue and open a work order, and every submission records who filed it.
5
+ - **Safee / DSCO syncs use a few batched requests.** Each sweep fetches every vehicle's current position, speed, heading and odometer in batches of up to 1,000 vehicles, with the vehicle list cached between sweeps. The old sync made three extra requests per vehicle; a 93-vehicle fleet now uses one request per minute instead of about 281. Temperature, door and history lookups stay out of routine polling.
6
+ - **Safee sign-in is reused and refreshed.** Access tokens are cached encrypted and refreshed before they expire, requests stay within Safee's 50-per-second account limit, and rate-limit responses are honoured.
7
+ - **Telematics can run on dedicated queue workers.** `TELEMATICS_POLL_QUEUE`, `TELEMATICS_INGESTION_QUEUE` and the new `TELEMATICS_BROADCAST_QUEUE` move polling, position processing and live-map broadcasts off the `default` queue for instances that need it. Nothing changes when they are unset. See `docs/TELEMATICS_QUEUES.md`.
8
+ - **Live telemetry status is easier to read.** Connection details show telemetry inside the page layout with a compact grid and collapsible setup and failure sections.
9
9
 
10
10
  ---
11
11
  ## Fixes
12
- - Place, zone and service area details, and the place and point map modals, no longer fail to render with "A resolved helper cannot be passed as a named argument".
12
+ - Telematics syncs no longer fail with "SyncTelematicDevicesJob has been attempted too many times" for providers on bounded polling. Poll attempts finish within the queue's 90-second reservation, and older queued sync jobs hand off to it instead of running for up to an hour.
13
+ - A transient provider or TLS timeout no longer pauses scheduled polling for several minutes, and connections left in an error state keep being polled.
14
+ - Vehicles that have never reported a position no longer mark every sweep as partial, quarantine deliveries, or show the connection as degraded.
15
+ - Older or delayed samples cannot move a device or attached vehicle position backwards, and partial messages keep existing device identity and metadata.
16
+ - AFAQY recovers from unreadable cached tokens and shares one request deadline across sign-in and unit retrieval.
17
+ - Pausing Safee polling no longer queues failing sync jobs every minute.
18
+ - Device event lookups use a new index instead of full-table scans.
19
+ - The Radar dashboard widget links correctly when rendered outside the Fleet-Ops engine.
13
20
 
14
21
  ---
15
22
  ## Testing
16
- - Model and controller contract tests cover inspection forms, submissions, links and the PIN lockout.
17
- - The Fleetbase Postman collection documents the `v1` inspection endpoints.
23
+ - Backend tests cover Safee batching, token refresh, rate limits, ordered ingestion, tenant isolation, delivery checkpoints, paused providers, retry backoff and queue routing.
24
+ - A local run on a single default queue worker polled a 93-vehicle Safee fleet: 5 sweeps used 7 API requests and each completed with 91 positions applied and 2 vehicles without a fix.
18
25
 
19
26
  ---
20
27
  ## Need help?
@@ -1,5 +1,5 @@
1
- <Telematic::TelemetryStatus @resource={{@resource}} @provider={{@resource.provider_descriptor}} />
2
1
  <div class="flex flex-col gap-4 p-4 md:p-6" ...attributes>
2
+ <Telematic::TelemetryStatus @resource={{@resource}} @provider={{@resource.provider_descriptor}} />
3
3
  <section class="grid grid-cols-1 gap-3 lg:grid-cols-3">
4
4
  {{#each this.healthCards as |card|}}
5
5
  <div class="fleet-ops-widget fleet-ops-kpi-tile fleetops-connectivity-kpi-tile {{card.accentClass}} relative min-h-[118px] overflow-hidden rounded-md border bg-white shadow-sm transition hover:-translate-y-px hover:shadow-lg dark:bg-gray-800">
@@ -1,43 +1,102 @@
1
- <div {{did-update this.resourceChanged @resource.id @resource.public_id this.provider.key this.supported}}>
1
+ <div class="contents" {{did-update this.resourceChanged @resource.id @resource.public_id this.provider.key this.supported}}>
2
2
  {{#if this.supported}}
3
- <section class="space-y-3 rounded-md border border-gray-200 p-3 dark:border-gray-700" data-test-telemetry-status>
4
- <h3 class="text-sm font-bold">Live telemetry</h3>
5
- {{#if this.error}}<p class="text-sm text-red-600" role="alert">{{this.error}}</p>{{/if}}
3
+ <section class="min-w-0 space-y-3 rounded-md border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800" data-test-telemetry-status>
4
+ <h3 class="text-sm font-bold text-gray-900 dark:text-gray-100">Live telemetry</h3>
5
+ {{#if this.error}}<p class="break-words text-xs text-red-600 dark:text-red-400" role="alert">{{this.error}}</p>{{/if}}
6
6
  {{#if this.diagnostics}}
7
- <dl class="grid grid-cols-2 gap-2 text-xs">
8
- <dt>Polling</dt><dd>{{if this.diagnostics.polling_enabled "Enabled" "Disabled"}}</dd>
9
- <dt>Position webhooks</dt><dd>{{if this.diagnostics.webhooks_enabled "Enabled" "Disabled"}} · {{smart-humanize this.diagnostics.webhook_state}}</dd>
10
- <dt>Last polling sweep</dt><dd>{{n-a this.diagnostics.last_poll.status}} · {{n-a this.diagnostics.last_poll.updated_at}}</dd>
11
- <dt>Units / pages / applied / failed</dt><dd>{{n-a this.diagnostics.last_poll.units}}
12
- /
13
- {{n-a this.diagnostics.last_poll.pages}}
14
- /
15
- {{n-a this.diagnostics.last_poll.applied}}
16
- /
17
- {{n-a this.diagnostics.last_poll.failed}}</dd>
18
- <dt>Last webhook received</dt><dd>{{n-a this.diagnostics.last_webhook.received_at}}</dd>
19
- <dt>Oldest pending delivery</dt><dd>{{n-a this.diagnostics.oldest_pending_at}}</dd>
20
- <dt>Latest queue / source delay (seconds)</dt><dd>{{n-a this.diagnostics.last_ingestion.queue_delay_seconds}}
21
- /
22
- {{n-a this.diagnostics.last_ingestion.source_delay_seconds}}</dd>
23
- <dt>Last polling error</dt><dd>{{n-a this.diagnostics.last_poll.error}}</dd>
7
+ <dl class="grid grid-cols-1 gap-x-4 gap-y-3 text-xs sm:grid-cols-2 lg:grid-cols-4">
8
+ <div class="min-w-0">
9
+ <dt class="text-gray-500 dark:text-gray-400">Polling</dt>
10
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{if this.diagnostics.polling_enabled "Enabled" "Disabled"}}</dd>
11
+ </div>
12
+ <div class="min-w-0">
13
+ <dt class="text-gray-500 dark:text-gray-400">Position webhooks</dt>
14
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{if this.diagnostics.webhooks_enabled "Enabled" "Disabled"}}
15
+ ·
16
+ {{smart-humanize this.diagnostics.webhook_state}}</dd>
17
+ </div>
18
+ <div class="min-w-0">
19
+ <dt class="text-gray-500 dark:text-gray-400">Last polling sweep</dt>
20
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a (smart-humanize this.diagnostics.last_poll.status)}}</dd>
21
+ </div>
22
+ <div class="min-w-0">
23
+ <dt class="text-gray-500 dark:text-gray-400">Sweep updated (UTC)</dt>
24
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_poll.updated_at}}</dd>
25
+ </div>
26
+ <div class="min-w-0">
27
+ <dt class="text-gray-500 dark:text-gray-400">Units fetched</dt>
28
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_poll.units}}</dd>
29
+ </div>
30
+ <div class="min-w-0">
31
+ <dt class="text-gray-500 dark:text-gray-400">Pages</dt>
32
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_poll.pages}}</dd>
33
+ </div>
34
+ <div class="min-w-0">
35
+ <dt class="text-gray-500 dark:text-gray-400">Applied</dt>
36
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_poll.applied}}</dd>
37
+ </div>
38
+ <div class="min-w-0">
39
+ <dt class="text-gray-500 dark:text-gray-400">Failed</dt>
40
+ <dd class="mt-1 break-words font-semibold {{if this.diagnostics.last_poll.failed 'text-red-600 dark:text-red-400' 'text-gray-900 dark:text-gray-100'}}">{{n-a
41
+ this.diagnostics.last_poll.failed
42
+ }}</dd>
43
+ </div>
44
+ <div class="min-w-0">
45
+ <dt class="text-gray-500 dark:text-gray-400">Last webhook (UTC)</dt>
46
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_webhook.received_at}}</dd>
47
+ </div>
48
+ <div class="min-w-0">
49
+ <dt class="text-gray-500 dark:text-gray-400">Oldest pending (UTC)</dt>
50
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.oldest_pending_at}}</dd>
51
+ </div>
52
+ <div class="min-w-0">
53
+ <dt class="text-gray-500 dark:text-gray-400">Queue delay (seconds)</dt>
54
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_ingestion.queue_delay_seconds}}</dd>
55
+ </div>
56
+ <div class="min-w-0">
57
+ <dt class="text-gray-500 dark:text-gray-400">Source delay (seconds)</dt>
58
+ <dd class="mt-1 break-words font-semibold text-gray-900 dark:text-gray-100">{{n-a this.diagnostics.last_ingestion.source_delay_seconds}}</dd>
59
+ </div>
24
60
  </dl>
61
+ {{#if this.diagnostics.last_poll.error}}
62
+ <p class="break-words text-xs text-red-600 dark:text-red-400"><span class="font-semibold">Last polling error:</span> {{this.diagnostics.last_poll.error}}</p>
63
+ {{/if}}
25
64
  {{/if}}
26
65
  {{#if this.supportsSecureWebhooks}}
27
- {{#if this.registrationInstructions}}<p class="text-xs">{{this.registrationInstructions}}</p>{{/if}}
28
- {{#if this.provisional}}<p class="text-xs">The payload adapter is provisional until a real delivery has been verified.</p>{{/if}}
29
- <button type="button" class="btn btn-default btn-sm" disabled={{this.configure.isRunning}} {{on "click" this.reveal}}>Show registration URL</button>
30
- {{#if this.webhookUrl}}
31
- <ClickToCopy @value={{this.webhookUrl}}><Input @value={{this.webhookUrl}} class="form-input w-full" readonly aria-label="Secure telemetry webhook URL" /></ClickToCopy>
32
- <p class="text-xs">This URL contains a secret. Rotating it immediately invalidates the previous URL and requires registration with the provider again.</p>
33
- <button type="button" class="btn btn-default btn-sm" disabled={{this.configure.isRunning}} {{on "click" this.rotate}}>Rotate secret and invalidate old URL</button>
34
- {{/if}}
66
+ <details class="border-t border-gray-200 pt-3 text-xs dark:border-gray-700">
67
+ <summary class="cursor-pointer font-semibold text-gray-700 dark:text-gray-300">Webhook setup</summary>
68
+ <div class="mt-3 space-y-2 text-gray-500 dark:text-gray-400">
69
+ {{#if this.registrationInstructions}}<p>{{this.registrationInstructions}}</p>{{/if}}
70
+ {{#if this.provisional}}<p>Position webhooks are awaiting verification with a real delivery.</p>{{/if}}
71
+ <Button @size="xs" @text="Show registration URL" @disabled={{this.configure.isRunning}} @onClick={{this.reveal}} />
72
+ {{#if this.webhookUrl}}
73
+ <ClickToCopy @value={{this.webhookUrl}}>
74
+ <Input @value={{this.webhookUrl}} class="form-input w-full text-xs" readonly aria-label="Secure telemetry webhook URL" />
75
+ </ClickToCopy>
76
+ <p>This URL contains a secret. Rotating it immediately invalidates the previous URL and requires registration with the provider again.</p>
77
+ <Button @size="xs" @text="Rotate secret" @disabled={{this.configure.isRunning}} @onClick={{this.rotate}} />
78
+ {{/if}}
79
+ </div>
80
+ </details>
81
+ {{/if}}
82
+ {{#if this.diagnostics.recent_failures.length}}
83
+ <details class="border-t border-gray-200 pt-3 text-xs dark:border-gray-700">
84
+ <summary class="cursor-pointer font-semibold text-red-600 dark:text-red-400">Recent delivery failures ({{this.diagnostics.recent_failures.length}})</summary>
85
+ <ul class="mt-3 max-h-48 space-y-3 overflow-y-auto">
86
+ {{#each this.diagnostics.recent_failures as |delivery|}}
87
+ <li class="flex flex-wrap items-start justify-between gap-2">
88
+ <div class="min-w-0 flex-1 break-words">
89
+ <p class="text-gray-500 dark:text-gray-400">{{delivery.received_at}} UTC</p>
90
+ <p class="mt-1 text-gray-700 dark:text-gray-300">{{delivery.error}}</p>
91
+ </div>
92
+ {{#if (eq delivery.status "quarantined")}}
93
+ <Button @size="xs" @text="Replay delivery" @onClick={{fn this.replay delivery.uuid}} />
94
+ {{/if}}
95
+ </li>
96
+ {{/each}}
97
+ </ul>
98
+ </details>
35
99
  {{/if}}
36
- {{#each this.diagnostics.recent_failures as |delivery|}}
37
- <div class="text-xs"><span>{{delivery.received_at}} · {{delivery.error}}</span>
38
- {{#if (eq delivery.status "quarantined")}}<button type="button" class="btn btn-default btn-sm" {{on "click" (fn this.replay delivery.uuid)}}>Replay delivery</button>{{/if}}
39
- </div>
40
- {{/each}}
41
100
  </section>
42
101
  {{/if}}
43
102
  </div>
@@ -18,7 +18,7 @@
18
18
  </div>
19
19
  <div class="mt-auto flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
20
20
  <span data-test-radar-widget-snoozed>{{this.stats.snoozed}} {{t "widget.radar.snoozed"}}</span>
21
- <LinkTo @route="management.index" class="inline-flex items-center gap-1 font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" data-test-radar-widget-link>
21
+ <LinkTo @route={{this.radarRoute}} class="inline-flex items-center gap-1 font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200" data-test-radar-widget-link>
22
22
  {{t "widget.radar.open-radar"}}
23
23
  <FaIcon @icon="arrow-right" class="text-[9px]" />
24
24
  </LinkTo>
@@ -1,7 +1,9 @@
1
1
  import Component from '@glimmer/component';
2
2
  import { tracked } from '@glimmer/tracking';
3
3
  import { inject as service } from '@ember/service';
4
+ import { getOwner } from '@ember/application';
4
5
  import { task } from 'ember-concurrency';
6
+ import config from '../../config/environment';
5
7
 
6
8
  /**
7
9
  * "Radar · 3 overdue": the dashboard tile that links to the page.
@@ -21,6 +23,11 @@ export default class WidgetRadarComponent extends Component {
21
23
  return this.summary?.summary ?? { open: 0, overdue: 0, snoozed: 0, critical: 0 };
22
24
  }
23
25
 
26
+ get radarRoute() {
27
+ // Dashboard widgets render under the host owner, outside the engine's routing context.
28
+ return getOwner(this).mountPoint ? 'management.index' : `${config.mountedEngineRoutePrefix}.management.index`;
29
+ }
30
+
24
31
  get accentClass() {
25
32
  if (this.stats.critical > 0 || this.stats.overdue > 0) {
26
33
  return 'kpi-accent-bad';
package/composer.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fleetbase/fleetops-api",
3
- "version": "0.6.66",
3
+ "version": "0.6.67",
4
4
  "description": "Fleet & Transport Management Extension for Fleetbase",
5
5
  "keywords": [
6
6
  "fleetbase-extension",
@@ -39,6 +39,7 @@
39
39
  "spatie/icalendar-generator": "^2.8"
40
40
  },
41
41
  "require-dev": {
42
+ "illuminate/encryption": "^9.0|^10.0",
42
43
  "friendsofphp/php-cs-fixer": "^3.34.1",
43
44
  "nunomaduro/collision": "^7.0",
44
45
  "pestphp/pest": "^2.33.2",
package/docs/AFAQY.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  AFAQY uses the provider-neutral telemetry infrastructure described in [TELEMETRY_ARCHITECTURE.md](TELEMETRY_ARCHITECTURE.md). Its adapter owns authentication and payload normalization; shared services own persistence, ingestion, and diagnostics.
6
6
 
7
- The existing AFAQY integration already used Units List. The new path requests 1,000 units per page with `simplify: 0` and the documented `basic` / `last_update` projection groups. It fetches all pages, places batches of 100 units in an encrypted durable inbox, and ingests them asynchronously. A connection in transient `error` status remains eligible; `disabled` connections and connections with `meta.telemetry_sync_enabled: false` do not.
7
+ The existing AFAQY integration already used Units List. The new path requests 1,000 units per page with `simplify: 0` and the documented `basic` / `last_update` projection groups. It fetches all pages, places configurable batches of units (100 by default) in an encrypted durable inbox, and ingests them asynchronously. A connection in transient `error` status remains eligible; `disabled` connections and connections with `meta.telemetry_sync_enabled: false` do not.
8
8
 
9
9
  Position webhooks use the same ingestion path and do not authenticate against AFAQY. The adapter provisionally accepts a unit object, a unit array, or a `data` envelope containing either, including `_id` with nested `data.last_update`. This is an assumption, not a verified AFAQY webhook contract. Unsupported authenticated JSON is quarantined and can be replayed after an adapter correction. Vehicle event webhooks and Signals history are outside this implementation.
10
10
 
@@ -12,30 +12,29 @@ Position webhooks use the same ingestion path and do not authenticate against AF
12
12
 
13
13
  ## Stage 1 deployment
14
14
 
15
- 1. Apply the Fleet-Ops migration `2026_09_15_000001_create_telematic_telemetry_tables`. It adds shared `telematic_deliveries`, `telematic_sync_runs`, and `telematic_webhook_credentials` tables for any registered telemetry adapter. It also adds a composite device lookup index; existing telemetry data is preserved.
16
- 2. Use a shared cache with distributed locks (Redis recommended) across all scheduler and worker instances. An array cache is suitable only for isolated tests. Cached login tokens are encrypted, scoped to account/host/password, and reused for up to 29 days; a credential change selects a new cache namespace. All API attempts for the same host/account share a rolling 60-request/minute budget.
15
+ 1. Apply the Fleet-Ops migrations `2026_09_15_000001_create_telematic_telemetry_tables` and `2026_09_17_000001_add_device_event_uuid_lookup_index`. The first adds shared `telematic_deliveries`, `telematic_sync_runs`, and `telematic_webhook_credentials` tables plus a composite device lookup index. The second ensures `device_events.uuid` has a usable leading index, including installations upgraded from the legacy events table. Existing telemetry data is preserved.
16
+ 2. Use a shared cache with distributed locks (Redis recommended) across all scheduler and worker instances. An array cache is suitable only for isolated tests. API, queue, and scheduler services must load the same effective `APP_KEY`, cache configuration, and telemetry settings, including Compose overrides and cached Laravel configuration. Preserve the established key that decrypts existing application data; do not generate a replacement as a troubleshooting step. Cached login tokens are encrypted, scoped to account/host/password, and reused for up to 29 days; a credential change selects a new cache namespace. All API attempts for the same host/account share a rolling 60-request/minute budget.
17
17
  3. Set the following environment variables and rebuild the application's configuration cache:
18
18
 
19
19
  ```dotenv
20
20
  AFAQY_POLLING_ENABLED=true
21
21
  AFAQY_WEBHOOKS_ENABLED=false
22
- AFAQY_POLL_QUEUE=afaqy-poll
23
- AFAQY_INGESTION_QUEUE=afaqy-ingest
24
22
  ```
25
23
 
26
- 4. Provision workers for both queues. For example:
24
+ 4. Keep the existing worker consuming the application's `default` queue, and restart long-running workers after configuration or code changes. Polling, ingestion, and broadcasts use that queue by default. The baseline requires no additional containers, dedicated queues, or custom batch size; ingestion batches default to 100 units.
27
25
 
28
- ```sh
29
- php artisan queue:work --queue=afaqy-poll --timeout=120 --tries=5
30
- php artisan queue:work --queue=afaqy-ingest --timeout=60 --tries=1
31
- ```
26
+ One worker can execute the whole pipeline: the polling job enqueues ingestion and returns without waiting for those jobs. Ingestion, broadcasts, and unrelated jobs then share that worker serially. Pending poll ingestion coalesces later polling ticks. This avoids accumulating obsolete sweeps, but it does not establish a particular freshness or throughput bound. Measure complete cycles using the deployment's actual worker count and workload.
27
+
28
+ Remove stale `TELEMATICS_POLL_QUEUE`, `TELEMATICS_INGESTION_QUEUE`, `AFAQY_POLL_QUEUE`, and `AFAQY_INGESTION_QUEUE` overrides unless the existing worker consumes those queues. Jobs sent to an unconsumed queue cannot progress.
32
29
 
33
- Set the queue connection's `retry_after` above 150 seconds (for example 180). Run multiple ingestion workers, and retain workers for the application's broadcast/default queue. Queue names default to `default` if not configured, for compatibility; dedicated workers prevent discovery or unrelated jobs from delaying telemetry.
30
+ Check that the effective queue reservation/retry period is compatible with the longest job the worker consumes, so the broker cannot make a still-running job available again. A worker's CLI timeout does not override a timeout declared by a job; manual discovery declares 3,600 seconds. This is a deployment compatibility check, not a requirement to add queues or prescribe a new reservation period as part of this patch.
34
31
 
35
- 5. Confirm the application scheduler executes every minute. It runs both `fleetops:sync-telematics` and `fleetops:drain-telematic-inbox`. The drain recovers accepted deliveries if broker dispatch failed or a worker stopped; no payload is acknowledged before durable persistence.
32
+ 5. Confirm the application scheduler executes every minute. It runs both `fleetops:sync-telematics` and `fleetops:drain-telematic-inbox`. The drain recovers accepted deliveries if broker dispatch failed or a worker stopped; no payload is acknowledged before durable persistence. Both commands use two-minute overlap leases so an interrupted scheduler cannot block telemetry for the former 24-hour default.
36
33
  6. Open the connection's live telemetry panel. Verify a full polling run transitions from fetching to ingesting to completed, with expected unit/page/applied counts. `incomplete` and `partial` are failures to investigate, not complete fleet coverage.
37
34
 
38
- Rate-limit retries honor `Retry-After` (bounded to one hour). Other polling failures use 15/60/180/300-second backoff, with five queue attempts. Poll HTTP requests have 20-second response and five-second connection limits. Sweeps stop at a 90-second fetch budget or 100 pages and report incomplete coverage. Pending poll ingestion coalesces later polling ticks. Failed ingestion retries only failed units; the original encrypted body remains available. Five failed/interrupted attempts quarantine the delivery.
35
+ Use the ordinary application scheduler for the baseline. Laravel executes foreground scheduled commands in registration order, so slow unrelated commands can delay polling and inbox recovery even when the telemetry queue is empty. Include scheduler execution time and application startup in diagnosis; distributed command locks are acquired only after Laravel boots. Additional scheduler isolation or worker capacity is an optional operational decision that requires separate measurement, not a prerequisite imposed by this integration.
36
+
37
+ Rate-limit retries honor `Retry-After` (bounded to one hour). Other polling failures use 15/60/180/300-second backoff, with five queue attempts. Poll HTTP requests default to a 45-second total request timeout, configurable with `TELEMATICS_REQUEST_TIMEOUT_SECONDS`, and a connection limit of at most five seconds. Each request is capped by the remaining 90-second fetch budget; changing the request setting does not extend that sweep budget. Sweeps also stop after 100 pages and report incomplete coverage. Pending poll ingestion coalesces later polling ticks. Failed ingestion retries only failed units; the original encrypted body remains available. Five failed/interrupted attempts quarantine the delivery. Internally generated polling batches are normalized per unit: a unit without a position is reported as invalid without discarding valid neighbors. Such a sweep reports partial coverage. External webhook payloads still require the adapter's supported position envelope.
39
38
 
40
39
  ## Stage 2 registration
41
40
 
@@ -58,22 +57,77 @@ Existing provider interfaces and vehicle/trailer event names remain compatible.
58
57
 
59
58
  The public receiver remains `POST webhooks/telematics/afaqy?telematic=...&key=...` under the application's API prefix. A new `device.telemetry_updated` broadcast identifies the device; an open device panel reloads its authorized resource and refetches after socket reconnection. Company broadcast identity comes from the persisted asset, not a worker session. Map movement uses the existing vehicle/trailer events, rejects older source timestamps, and reloads visible telemetry assets after socket reconnection with at most five concurrent requests.
60
59
 
61
- Configure shared queues, retention, limits, and freshness under `telematics.telemetry` using `server/config/telemetry.php`. AFAQY's adapter switches and optional queue overrides remain under `telematics.afaqy` in `server/config/afaqy.php`. Defaults: two MiB per webhook, 10,000 pending deliveries per connection, processed payload retention 24 hours, quarantine retention seven days, stale-position thresholds 120 seconds with ignition on and 600 seconds with ignition off/unknown. Each device's metadata records the effective threshold. Connection status categories remain unchanged.
60
+ Configure shared queues, retention, limits, and freshness under `telematics.telemetry` using `server/config/telemetry.php`. `TELEMATICS_BATCH_SIZE` controls units per ingestion delivery (default 100); `TELEMATICS_REQUEST_TIMEOUT_SECONDS` controls the bounded polling request timeout (default 45 seconds). AFAQY's adapter switches and optional queue overrides remain under `telematics.afaqy` in `server/config/afaqy.php`. Defaults: two MiB per webhook, 10,000 pending deliveries per connection, processed payload retention 24 hours, quarantine retention seven days, stale-position thresholds 120 seconds with ignition on and 600 seconds with ignition off/unknown. Each device's metadata records the effective threshold. Connection status categories remain unchanged.
62
61
 
63
62
  ## Incident verification
64
63
 
65
- For the screenshot's vehicle, compare the same AFAQY unit ID/IMEI and attached Fleetbase asset. Fetch its Units List record and compare raw UTC `dtt`/`dts` with `device.meta.telemetry`, `last_online_at`, and `vehicle.telematics.last_event_at`. Then inspect polling-run status, oldest pending delivery, worker/broadcast queue delay, and the open browser panel. A June-versus-September date gap cannot be accounted for by UTC+3 alone. No affected production account was accessed during implementation.
64
+ ### September 2026 Docker investigation
65
+
66
+ The affected Docker deployment was investigated using live AFAQY responses and its real database, queue workers, and scheduler. The initial implementation's isolated tests had not exercised that deployment. The observed June-to-September gap was not a timezone conversion issue.
67
+
68
+ The investigation identified several independent failures:
69
+
70
+ - **Encryption configuration differed between services.** An application-only Compose override made the API and queue workers load different effective encryption keys. The API's established key successfully decrypted existing ledger and inspection records; that key was preserved and shared across services. Cached-token recovery remains necessary, but repeatedly refreshing tokens cannot repair inconsistent service configuration.
71
+ - **Event lookups scanned a large legacy table.** The deployment had approximately 50,000 event rows occupying 1.2 GB, with no index on `device_events.uuid`. Activity logging refreshes saved models by UUID, producing measured 10–13-second table scans. The UUID-index migration was applied; the measured UUID lookup then took about 7 ms. A subsequent real single-unit ingestion took approximately 1.7 seconds across 25 SQL statements, with about 249 ms spent in SQL. These are different measurements—a prior lookup versus a complete later ingestion—not a controlled whole-fleet speedup ratio.
72
+ - **Manual sync progress was invisible during ingestion.** The job fetched 381 units but retained queued/zero-page metadata while doing database work. It now persists running phases and fetched totals before ingestion, then checkpoints completed counters every 25 records or five seconds and at page boundaries. The manual 381-unit inventory sync completed after the database correction.
73
+ - **Some inventory records had no position.** The live response contained 381 units, of which 379 had valid positions and two lacked `last_update`. The polling worker now processes valid records independently and reports the other two as invalid; it does not discard their whole batches or count them as applied telemetry.
74
+ - **The request deadline was too short for an observed response.** One live Units List response took about 23.6 seconds, exceeding the former 20-second limit. The configurable 45-second request timeout remains constrained by the overall fetch budget.
75
+ - **Automatic processing was not running.** The rollout polling switch was disabled and the general development scheduler was stopped. Enabling it dispatched polling and unrelated scheduled workload. Investigation temporarily used a dedicated telemetry scheduler, four generic workers, and batches of 25. That experiment changed the deployment's worker topology and does not validate the user's single-worker baseline. The temporary services, dedicated queue settings, batch override, and queue reservation changes have since been removed. Configuration is restored to the existing default worker and ordinary application scheduler, retaining the shared established encryption key, enabled polling, schema index, and code corrections.
76
+ - **Scheduler interruption left a 24-hour overlap lock.** Recreating the scheduler during execution left its default overlap lease in the shared cache, preventing later polling dispatches even with empty worker queues. The two telemetry scheduled commands now use two-minute overlap leases, and the sync command's own lock also expires after two minutes. During recovery, only the confirmed abandoned telemetry mutex was cleared after stopping the scheduler; unrelated cache entries and locks were preserved.
77
+
78
+ During the temporary four-worker experiment, the initial eleven automatic sweeps drained 379 valid position records per sweep and explicitly reported the two records without positions as partial coverage. Those sweeps ranged from 22 to 65 seconds with a median of 36 seconds. A later sweep during scheduler recreation and heavy unrelated scheduled workload took 152 seconds; 22–65 seconds is therefore not the full observed range. The subsequent run stalled with retained inbox batches while the host was under heavy load. Its Docker VM had eight CPUs and about 8 GB of memory; the host reported about 16 GB of swap in use and a load average near 48. Pausing a separate stack did not immediately restore normal startup. A bounded startup probe measured about 12 seconds for Composer autoload and about 40 seconds for provider registration/boot, excluding ingestion. Temporary dedicated cron startup wrappers were also removed with that experiment. Recovery and repeated-cycle cadence on the restored single-worker baseline remain open verification gates.
79
+
80
+ These observations establish that the inspected 381-unit fleet was fetched and ingested during the investigation. They do not establish single-worker performance, a consistent sub-minute sweep, the 5,000-unit target, webhook throughput, or end-to-end browser latency. Actual AFAQY webhook delivery remains unverified.
81
+
82
+ ### Verification checklist
83
+
84
+ 1. Compare the same provider unit identifier across the live Units List record, persisted device, and attached asset. Compare raw UTC `dtt`/`dts` with `device.meta.telemetry`, `last_online_at`, and the asset's source timestamp.
85
+ 2. Verify effective API/worker/scheduler configuration, not only the `.env` file. Compare keys securely without printing them; confirm the established key decrypts existing records before aligning services. Rebuild cached configuration and restart long-running processes after changes.
86
+ 3. Check applied migrations and the actual query plan for UUID-based event lookup. Confirm the lookup uses an index instead of scanning historical event payloads.
87
+ 4. Verify polling is enabled, the scheduler invokes both telemetry commands every minute, and workers consume the effective polling, ingestion, and broadcast queues. Watch pending/retry/processing delivery age and depth. After interruption, inspect the specific scheduler mutex and queued/reserved poll jobs before clearing anything. New scheduler leases expire after two minutes, but an old 24-hour lease already stored in cache keeps its original expiry; remove only a confirmed abandoned telemetry lock. Do not flush the shared cache or clear all scheduled-task locks.
88
+ 5. Observe several complete runs. Reconcile fetched, applied, and invalid/failed counts; inspect quarantined records safely. A run with units lacking positions must remain explicitly partial.
89
+ 6. Confirm the stored device and asset advance with newer source timestamps and verify their rendered state separately. Database ingestion success does not by itself validate browser/socket delivery.
90
+
91
+ ## Cached-token decryption failures
92
+
93
+ A manual sync runs `SyncTelematicDevicesJob`; scheduled telemetry polling uses
94
+ `PollTelematicTelemetry`. Both authenticate through the AFAQY provider. Previously,
95
+ an unreadable encrypted access token in the shared cache raised Laravel's
96
+ `DecryptException` before the provider could refresh the token. This is a cache
97
+ recovery defect; a MAC error alone does not establish that deployment keys differ.
98
+
99
+ The provider now catches decryption failure only for its disposable cached token,
100
+ evicts that one entry under the existing account lock, and authenticates again
101
+ using the already-resolved connection credentials. A replacement token is cached
102
+ encrypted. Login failures and rate limits still propagate. Persisted credentials,
103
+ webhook secrets, and inbox payloads are not reset or treated as plaintext.
104
+ A warning with reason `cached_token_decryption_failed` identifies this path without
105
+ logging secrets. Deploy the patch and restart long-running queue workers before
106
+ retrying. If the exception remains, capture the calling stack frames without
107
+ arguments to identify which encrypted value failed; do not rotate APP_KEY or flush
108
+ the entire shared cache as a diagnostic step.
109
+
110
+ Regression coverage now uses Laravel's real encrypter (`illuminate/encryption` is
111
+ a development dependency), persisted encrypted credentials, and both manual and
112
+ scheduled job handlers. Tampered MACs and foreign-key cache entries fail before
113
+ the fix and recover afterward. Provider HTTP responses remain simulated; these
114
+ checks do not prove successful authentication against a live AFAQY account.
66
115
 
67
116
  ## Validation and acceptance gates
68
117
 
69
118
  Run the new provider contract and database ingestion suites with the package Pest runner, and the provider-neutral telemetry browser tests with Ember:
70
119
 
71
120
  ```sh
72
- php scripts/pest-runner.php server/tests/Unit/Support/Telematics/Providers/AfaqyRealtimeContractTest.php
73
- php scripts/pest-runner.php server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
121
+ php scripts/pest-runner.php server/tests/Unit/Support/Telematics/Providers/AfaqyRealtimeContractTest.php --debug
122
+ php scripts/pest-runner.php server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php --debug
123
+ php scripts/pest-runner.php server/tests/Feature/Http/TelematicMixedFleetDeliveryTest.php --debug
124
+ php scripts/pest-runner.php server/tests/Feature/Http/Api/DeviceEventUuidIndexMigrationTest.php --debug
125
+ php scripts/pest-runner.php server/tests/SupportJobAndAiCoverageTest.php --filter='manual sync' --debug
74
126
  node_modules/.bin/ember test --filter=telemetry
75
127
  ```
76
128
 
129
+ The incident patch passed 92 focused backend tests. The incident regressions exercise valid–invalid–valid polling batches through encrypted inbox processing and database ingestion, strict webhook quarantine, UUID-index migration behavior, and manual progress checkpoints/partial failures. The mixed-fleet SQLite fixture disables model listeners and external transports, and the manual-progress tests use provider/service doubles; those tests alone would not expose the deployed activity-log table scan. The live Docker investigation above supplies separate evidence for the actual MySQL schema and model path. Use `--debug` to retain PHPUnit test-pass events and its final exit status when the normal package formatter emits no summary.
130
+
77
131
  The original implementation previously processed 5,000 signals plus 5,000 duplicate reconciliation observations in 45.9 seconds in the opt-in local SQLite benchmark (`XDEBUG_MODE=off AFAQY_RUN_LOAD_TESTS=1` with the ingestion suite above). During the provider-neutral refactor review, the final run took 66.5 seconds and failed the local 60-second budget; the original commit repeated in the same runtime took 83.4 seconds. Different machine/build load prevents a controlled speed comparison. The load gate remains open. It uses real model/database ingestion with indexed fixture tables, simulated spatial functions, and captured broadcasts; it excludes provider networking, broker latency, attached-asset load, and browser rendering.
78
132
 
79
133
  The original implementation validation passed 103 selected backend tests, including database ingestion, sensor preservation, retention recovery, and provider contracts. A separate check against the installed Laravel dispatcher verified that broker failures release uniqueness leases. The standard browser launcher is blocked by the linked Testem/execa CommonJS/ES-module mismatch; the two focused browser tests passed all nine assertions in an isolated local harness with lazy engine assets and dummy host configuration. These cover live panel refresh, reconnect, freshness labels, map movement, and reversed broadcasts; they do not exercise a production socket server. PHPStan is not clean in the package-only runtime (unresolved Laravel helpers/model types and strict type findings); it is not counted as a passing gate.
package/docs/SAFEE.md ADDED
@@ -0,0 +1,125 @@
1
+ # Safee / DSCO live polling
2
+
3
+ ## Problem and API contract
4
+
5
+ The legacy sync fetched inventory and current state, then made three serial
6
+ requests per vehicle (`last-info`, `positions`, and `events`) before applying a
7
+ second ingestion pass. A 93-vehicle fleet required 281 data requests, excluding
8
+ authentication. Each enrichment request could wait up to 120 seconds. Historical
9
+ samples could overwrite the latest position, and failed connections were excluded
10
+ from subsequent scheduled discovery.
11
+
12
+ Safee's [Tracking REST Service V2.2.4.0](https://www.safee.com/_docs_/Safee_Tracking_REST_Service.pdf)
13
+ documents:
14
+
15
+ - `POST /api/v2/vehicle/list-info` with an empty JSON object for inventory.
16
+ - `POST /api/v2/vehicle/last-state` with `live: true`, `endDate: null`, and a
17
+ `vehicles` ID array to retrieve current state, position, speed, heading, and
18
+ available counters.
19
+ - Up to 1,000 vehicle IDs per request, and 50 requests per user per second.
20
+ - Unix timestamps with fractional seconds. The response envelope's `time` is not
21
+ a vehicle's source timestamp.
22
+ - OAuth access-token expiry and refresh-token grants.
23
+
24
+ There are no position webhooks for this integration. Temperature/door enrichment
25
+ and historical position/event retrieval remain outside routine live polling.
26
+ Existing enrichment APIs remain available for explicit use.
27
+
28
+ ## Polling and ingestion
29
+
30
+ Safee opts into the existing provider-neutral telemetry pipeline. No Safee tables,
31
+ UI branches, dedicated queues, or additional workers are introduced.
32
+
33
+ 1. The minute scheduler coalesces queued/running polling and pending ingestion.
34
+ Enabled connections remain eligible after transient errors. When
35
+ `SAFEE_POLLING_ENABLED=false`, Safee is paused; it does not fall back to the
36
+ legacy monolithic discovery job.
37
+ 2. Inventory is cached briefly. Manual discovery refreshes it. Pagination uses
38
+ stable inventory slices; `list-info` itself is not treated as a paginated API.
39
+ 3. Each slice retrieves current states in one batch of at most 1,000 IDs. Missing
40
+ states are reported per unit; malformed, duplicate, or unsuccessful responses
41
+ cannot masquerade as successful empty fleets.
42
+ 4. Raw batches enter the shared encrypted durable inbox. Existing default-queue
43
+ jobs apply them transactionally with per-device serialization, deduplication,
44
+ and source-time ordering. Older samples cannot move current device or attached
45
+ asset positions backwards.
46
+ 5. Units without a valid fix (for example source date `0`) keep their device link
47
+ and are counted in `invalid_count`, but a polled delivery is not quarantined
48
+ and its run is not marked partial for them. Pushed deliveries keep quarantine.
49
+ 6. Partial messages preserve attachment identity and existing metadata, counters,
50
+ and sensors. A zero source date is treated as missing data. Source timestamps
51
+ are converted to UTC; receipt time is never substituted for a missing GPS time.
52
+
53
+ A fresh fleet sweep uses `1 + ceil(N / 1000)` data requests; subsequent sweeps use
54
+ `ceil(N / 1000)` while inventory is cached. These are request-count improvements,
55
+ not guarantees about provider latency or application throughput.
56
+
57
+ Authentication is cached using credential-sensitive keys and encrypted tokens.
58
+ Expiry is honored, a rejected token may be refreshed once, and authentication,
59
+ manual syncs, and retries share the per-user request budget. HTTP429 respects
60
+ `Retry-After`; connection/server failures use bounded queue retry delays. Scheduled
61
+ poll retries wait at most 60 seconds (15, 30, 60, 60) so a transient login or TLS
62
+ timeout cannot suppress minute polling for several minutes; manual requests keep
63
+ 15, 60, 180, 300 seconds. Previously queued legacy discovery jobs exit quietly when
64
+ polling is paused or already queued instead of marking the connection as errored.
65
+
66
+ ## Configuration and rollout
67
+
68
+ Safee polling is enabled by default because it replaces the previous scheduled
69
+ sync. `SAFEE_POLLING_ENABLED=false` pauses Safee polling/manual batch sync.
70
+ `meta.telemetry_sync_enabled=false` or a disabled connection also prevents work.
71
+ Webhooks remain unsupported.
72
+
73
+ - `SAFEE_INVENTORY_CACHE_SECONDS`: default300, capped at300seconds.
74
+ - `SAFEE_REQUEST_TIMEOUT_SECONDS`: default30seconds, capped by the remaining sweep
75
+ budget. Authentication and data requests share that budget.
76
+ - Existing `TELEMATICS_POLL_QUEUE` and `TELEMATICS_INGESTION_QUEUE` default to
77
+ `default`. One existing queue worker can process both types of jobs.
78
+ To run telematics on dedicated workers for one instance, see
79
+ [Dedicated telematics queue workers](TELEMATICS_QUEUES.md).
80
+ - Poll attempts use an80-second hard timeout and a60-second cooperative sweep
81
+ budget, below the baseline Redis `retry_after=90` seconds.
82
+
83
+ Apply the existing generic telemetry migrations and device-event lookup index,
84
+ then reload long-running workers so they use the new provider class. Clear/rebuild
85
+ cached configuration as part of the application's normal deployment process.
86
+ Do not retry an old failed monolithic job to recover a connection; start a new
87
+ manual sync or let scheduled polling recover it.
88
+
89
+ Inspect the shared telemetry diagnostics for fetched/applied/failed counts,
90
+ incomplete sweeps, pending deliveries, queue delay, and source age. Manual sync
91
+ completion is tied to its ingestion run; queueing or fetching alone is not
92
+ reported as a completed sync.
93
+
94
+ ## Validation boundaries
95
+
96
+ The local DSCO connection had93 stored units. A read-only API probe on
97
+ 2026-09-17 authenticated in2.466seconds, listed all93 units in4.136seconds,
98
+ and fetched all93 current states in3.932seconds. The returned access token
99
+ expired after300seconds. Some units returned source date0; these are invalid
100
+ positions and must remain visible as partial data rather than fabricated fixes.
101
+
102
+ The immediately preceding legacy worker failure was an SSL connection timeout
103
+ at the login endpoint. Subsequent authenticated requests succeeded. This shows
104
+ an intermittent provider/network failure as well as the independently verified
105
+ request-volume, token-refresh, and scheduler-recovery defects.
106
+
107
+ Contract and database tests use controlled fixtures; they do not prove production
108
+ throughput. Production freshness remains bounded by Safee/device reporting,
109
+ provider response time, the minute polling interval, and available capacity of
110
+ the shared worker. Monitor queue growth with the rest of the application's jobs.
111
+
112
+ ### Local default-worker run (2026-09-17, 06:40-06:46 UTC)
113
+
114
+ After restarting the single existing `queue:work` worker on the local stack, five
115
+ scheduled sweeps of the 93-unit DSCO connection made 7 Safee requests in total:
116
+ 1 token, 1 `list-info`, and 5 `last-state`. Every run completed with 93 units,
117
+ 91 applied, 2 counted as invalid, one processed delivery, no Safee failed jobs, and
118
+ 9-15 seconds from run creation to processed delivery. The legacy design would have
119
+ made about 281 data requests per sweep.
120
+
121
+ Only 42 of the 91 positioned units had a source fix under five minutes old (median
122
+ age 386 seconds). This reflects when devices last reported; faster polling cannot
123
+ improve it. AFAQY polling on the same worker hit 44-second `units/lists` timeouts
124
+ during this window, and those jobs share that worker's capacity. This run is local
125
+ evidence, not production acceptance.