Skip to content
Sergio Hernandez edited this page Jul 24, 2026 · 5 revisions

Router

The Router is TrackHub's multi-protocol integration layer. It connects to external GPS tracking providers — each with its own API technology (REST, GraphQL, SOAP, vendor SDK) — and standardizes their position data into a unified format the rest of the platform consumes.

It has no database of its own. Master data is read and written through the Manager; positions, history, health checks and sync runs go to Telemetry. The repository also contains the SyncWorker, the background host that drives synchronization.

Related pages: Adding a Provider · Telemetry · Inter-Service Communication


Multi-protocol integration

graph LR
    subgraph Providers["GPS Tracking Providers"]
        rest["REST API<br/><i>e.g. Traccar</i>"]
        gql["GraphQL API"]
        soap["SOAP / XML"]
        sdk["Vendor SDK<br/><i>e.g. Geotab</i>"]
    end

    subgraph Router["TrackHub Router"]
        adapter["Protocol Readers<br/><i>keyed by ProtocolType</i>"]
        norm["Data Normalizer"]
        cache["Session / Token Store"]
    end

    rest -->|HTTP/JSON| adapter
    gql -->|HTTP/GraphQL| adapter
    soap -->|HTTP/XML| adapter
    sdk -->|SDK| adapter

    adapter --> norm
    cache --- adapter
    norm --> unified["Unified Position Model<br/><i>TransporterId, Lat, Lng, Speed,<br/>Altitude, EventDate, Attributes</i>"]
Loading

Downstream services and the portal always work with the same data shape, whatever the provider underneath.


Supported protocols

Protocol Description Authentication Real-time positions Position history
CommandTrack CommandTrack REST API Bearer token (absolute expiry, persisted to Manager)
Traccar Open-source tracking server Basic auth
Flespi Flespi telematics platform Static token header
GeoTab Geotab fleet management (vendor SDK) Session-based (cached; the SDK self-heals)
GpsGate GpsGate tracking platform Static token header ❌ (no history API)
Navixy Navixy tracking platform Login → session hash (cached)
Samsara Samsara fleet platform Static bearer token
Wialon Wialon hosting platform Token login → session id (cached)
Protrack Protrack GPS platform MD5-signature → bearer token (absolute expiry, persisted to Manager)

Mettax is reserved in the ProtocolType enum but has no provider assembly yet.

Enabled protocols are listed in configuration:

AppSettings__Protocols__0=CommandTrack
AppSettings__Protocols__1=Flespi
AppSettings__Protocols__2=GeoTab
AppSettings__Protocols__3=GpsGate
AppSettings__Protocols__4=Navixy
AppSettings__Protocols__5=Protrack
AppSettings__Protocols__6=Samsara
AppSettings__Protocols__7=Traccar
AppSettings__Protocols__8=Wialon

Provider registration

Registration is keyed, uniform, case-insensitive and fail-fast (ProtocolRegistrationExtensions):

  • Each provider's DeviceReader, PositionReader and ConnectivityTester is registered keyed by its ProtocolType (AddKeyedTransient).
  • PositionRegistry, DeviceRegistry and ConnectivityRegistry are scoped and resolve exactly one reader per lookup from the caller's live request scope via GetKeyedService — no resolve-all-and-filter, no disposed-scope hand-off.
  • Readers are keyed transient and HttpClientService is transient, so two concurrent same-protocol operators in one sync scope never share mutable Init state.
  • The AppSettings:Protocols entry resolves the assembly's real namespace regardless of casing (GeoTabGeotab).

There is one uniform registration path. The old fake-async adapter subsystem is gone: every provider's Init is a real Task, and each reader implements IPositionReader / IExternalDeviceReader directly.

Failure modes are loud, not silent:

Situation Result
A configured protocol resolves no assembly or no reader types Throws at startup
A capability declaration mismatches the reader classes in its assembly Throws at startup
An operator's ProtocolTypeId has no registered reader ProtocolNotSupportedException, naming the protocol — never a masked .First()

Provider capabilities

What each provider's API actually supports is declared once, in TrackHub.Router.Domain.Constants.ProviderCapabilityCatalog, as ProviderCapability flags: RealTimePositions, PositionHistory, DeviceCatalog, ConnectivityPing. GpsGate lacks PositionHistory; Mettax is None.

The catalog is exposed to clients through the Router's providerCapabilities GraphQL query (Positions/Read, [PlatformScoped]).

Requesting something the operator's provider cannot do throws ProviderCapabilityNotSupportedException → GraphQL code PROVIDER_CAPABILITY_NOT_SUPPORTED, with protocol and capability extensions. This is deliberately distinct from:

  • FEATURE_DISABLED, which means TrackHub account/feature gating;
  • ProtocolNotSupportedException, which means no reader is registered.

The distinction matters commercially: a paying customer must be able to tell that the limitation is their GPS provider's, not TrackHub's. The message says so explicitly.

Enforcement happens in PositionBaseHandler before the provider call; GpsGate's history stub throws the same typed exception as defense in depth. Never let such a request reach the provider, degrade to empty results, or surface as a masked server error.

The limitation applies only to provider-side history replay. Accounts with the gps.positionHistory feature still get TrackHub-stored history for any provider, because the Router records synced positions regardless.


Data synchronization

sequenceDiagram
    participant SW as SyncWorker
    participant Auth as AuthorityServer
    participant Router as Router API
    participant Mgr as Manager API
    participant GPS as GPS Provider
    participant Tele as Telemetry API
    participant Geo as Geofencing API
    participant Trip as TripManagement API

    SW->>Auth: Client Credentials Grant
    Auth-->>SW: Access Token (syncworker_client)

    SW->>Router: accountsToSync()
    Router->>Mgr: accountsToSync()
    Mgr-->>Router: Accounts list
    Router-->>SW: Accounts list

    loop For each Account (concurrently, under a global operator gate)
        SW->>Router: operators(accountId)
        Router->>Mgr: operatorsMaster(accountId)
        Mgr-->>Router: Operators + Credentials
        Router-->>SW: Operators

        loop For each Operator
            SW->>GPS: Authenticate (cached session / token)
            GPS-->>SW: Session or new token

            alt Token refreshed
                SW->>Router: updateToken(credentialId, newToken)
                Router->>Mgr: updateToken(...)
            end

            SW->>GPS: Fetch device positions
            GPS-->>SW: Raw position data

            SW->>Tele: bulkTransporterPosition(...)
            SW->>Tele: appendPositionHistoryBatch(...)
            SW->>Geo: processPositions(...)
            SW->>Trip: processTripPositions(...)
            SW->>Tele: recordOperatorSyncRun(...)
        end
    end

    Note over SW: Reverse geocoding runs AFTER the projection write
Loading

Pipeline behaviour

  • Accounts are processed concurrently, under a single global operator-concurrency gate (AppSettings:MaxConcurrentOperatorSyncs, default 10), so one slow account cannot stall the rest while total provider load stays bounded.
  • Reverse geocoding runs after the latest-projection write and geofence detection. Enrichment re-upserts the projection only when it resolved a blank address, so it never delays position freshness.
  • The operator device catalog is cached (IDeviceCatalogCache, AppSettings:DeviceCatalogCacheSeconds, default 60) and invalidated by the device-sync loop, instead of re-fetched from Manager every cycle.
  • A persistently failing operator backs off exponentially (IOperatorSyncBackoff, 1 → 30 min) out of the device-sync and health loops until its first success.
  • Provider failures are recorded, not swallowed. A position fetch that throws is logged and carried on PositionsRetrieved.Notification (ProviderErrorCode), which records the sync run as FAILED with the error and raises GpsOperatorPositionSyncFailed — no more silent SUCCEEDED with read = 0. A single failing operator does not abort the account fan-out or skip the interval update.
  • The Geofencing feed is best-effort and isolated, so a Geofencing outage cannot flip an already-stored batch to FAILED.
  • Every device-sync attempt records a health observation (checkType = DEVICE_SYNC; HEALTHY when the provider answered, OFFLINE when it did not), under the Router's service identity. Accounts that only sync manually — no SyncWorker, no gps.integration — still get a meaningful Health status. The worker's 1-minute PING loop remains the background monitor for GPS-enabled accounts.

Provider sessions

Session and token artifacts are cached in-process per credential in a singleton IProviderSessionStore, keyed by CredentialId and fingerprinted by the full decrypted credential — so a credential rotation is automatically a cache miss.

Provider Cached artifact Lifetime
Wialon sid 4 min sliding
Navixy session hash 30 min sliding
Geotab SDK session id 8 h sliding (API constructed with password + sessionId so the SDK self-heals; readers call PersistSession() after calls)
CommandTrack, Protrack Bearer token Durable copy in Manager (UpdateTokenAsync) plus a non-sliding in-process fast path

Wialon and Navixy re-login only on a miss and retry once after re-auth on an invalid-session error. That also fixed both providers' HTTP-200 error payloads, which previously deserialized into silent empty results — IWialonResponse.Error and INavixyResponse.Success + Status now throw.

Connectivity Ping always performs a REAL provider round-trip. A cached session must never no-op the health monitor.

The store is in-process, consistent with the single-instance SyncWorker deployment.

Concurrency guards

Guard Scope Why
IOperatorSyncLock Per operator, in-process singleton The manual-sync and background device-sync paths both acquire it in SyncOperatorDevicesCommandHandler, so a manual "sync now" cannot race the background loop and cause a ResetDeviceCatalog wipe/rebuild conflict
ExecutionIntervalManager Position loop interval, in-memory Single-instance by design
MaxConcurrentOperatorSyncs Global Bounds total provider load across concurrent accounts

Both guards assume a single SyncWorker instance. Scaling the worker out would require a cross-instance claim (a PostgreSQL advisory lock or SKIP LOCKED).

Manual sync

triggerOperatorSync is an authorized user feature, not an internal-only operation. It carries [Authorize(Resource = Resources.Credentials, Action = Actions.Custom)] — the same grant portal roles hold for PingOperator — plus [RateLimiting]. Any provider-touching operation reachable on demand must ship with both.


Position attributes

AttributesVm keeps five promoted fields strongly typed — ignition, satellites, mileage, hourmeter, temperature — and carries everything else in an open Extra bag (a JSON object string, built and read through Domain.Helpers.AttributesExtra).

Extra maps 1:1 onto Telemetry's additive extra: String field on AttributesDtoInput / AttributesVm. Because the underlying column is a schemaless json, adding a new provider signal requires no migration and no cross-service schema change. It round-trips on read and flows through the history path.

Units at the boundary:

  • Hourmeter is in hours (its producer is a Hobbs meter reading decimal engine hours).
  • Mileage has no conversion applied at the mapper boundary — a consumer must not assume metres or kilometres without checking the provider.
  • Speed is km/h, coordinates are decimal degrees, timestamps are UTC DateTimeOffset.

The wire name is hourmeter everywhere. CommandTrack's payload calls it hobbsMeter and is remapped only at that provider's model, with [JsonPropertyName("hobbsMeter")].


Service dependencies

graph LR
    Router["Router API"]
    Manager["Manager API<br/><i>Operators, devices,<br/>credentials, master data</i>"]
    Telemetry["Telemetry API<br/><i>Positions, history,<br/>health, sync runs</i>"]
    Geofencing["Geofencing API<br/><i>Visit detection</i>"]
    Trip["TripManagement API<br/><i>Trip detection</i>"]

    Router -->|"GraphQL (read/write)"| Manager
    Router -->|"GraphQL (write)"| Telemetry
    Router -->|"GraphQL (write)"| Geofencing
    Router -->|"GraphQL (write)"| Trip
Loading

The Router pushes one position per transporter per call to the detection feeds. Any debounce or run length held in memory downstream could therefore never elapse — see Trip Management.


Web surface

  • GraphQL at /graphql — operators, devices, positions, position history, provider capabilities, sync dispatch.
  • REST — a third-party integration surface for reading unit information, documented through Scalar.
  • /healthAddHealthChecks() + UseHealthChecks("/health"), per the canonical middleware order.

Provider HttpClients disable automatic redirects: an operator-configured base URL must not be able to 302-redirect the Router inward.

Clone this wiki locally