Skip to content

Adding a Provider

Sergio Hernandez edited this page Jul 26, 2026 · 2 revisions

Adding a GPS Provider to the Router

This guide is for developers integrating a new GPS tracking platform ("provider") into TrackHub. A provider is a self-contained class library inside the Router solution that translates one external API into the Router's uniform contracts.

Providers are compiled in, activated by configuration: every provider project ships with the Router, and AppSettings:Protocols decides which ones register at startup. There is no runtime plugin loading — a misconfigured or missing provider fails at startup, loudly, on purpose.

Existing providers to crib from: Traccar (simplest — static Basic-auth header, and the recommended template), Samsara / Flespi / GpsGate (static token header), Wialon / Navixy (login → cached session id), Geotab (vendor SDK, cached session), CommandTrack / Protrack (bearer token with absolute expiry, persisted back to Manager).

Related pages: Router · Common Library


1. The contract

A provider implements up to three interfaces from TrackHub.Router.Domain.Interfaces, each in its own class with an exact, convention-bound name:

Class name Interface Used by
DeviceReader IExternalDeviceReader Device-catalog sync (SyncWorker + manual "sync now")
PositionReader IPositionReader Position sync loop + on-demand position queries
ConnectivityTester IConnectivityTester 1-minute health PING loop + the operator "ping" button

Registering at least one of the three passes startup validation. The two capabilities clients actually depend on are real-time positions and position history (both on IPositionReader); the rest supports the platform's own plumbing — catalog reconciliation and health monitoring.

Capability declaration

Each provider assembly ships exactly one ProviderDescriptor class implementing IProviderDescriptor in its root namespace, declaring the protocol, a display name, and a ProviderCapability flags value over RealTimePositions, PositionHistory, DeviceCatalog and ConnectivityPing. The runtime capability catalog (IProviderCapabilityCatalog) is assembled at startup from the discovered descriptors, plus None placeholders for reserved enum values with no assembly.

This catalog is the client-facing truth. The providerCapabilities GraphQL query exposes it (it is also the portal's provider list — there is no client-side protocol table to maintain), and a request for an undeclared capability fails with the typed error PROVIDER_CAPABILITY_NOT_SUPPORTED (ProviderCapabilityNotSupportedException), whose message attributes the limitation to the GPS provider. That is never a masked server error a paying client would read as a TrackHub defect, and never confused with FEATURE_DISABLED (TrackHub account gating).

If the provider's API genuinely lacks position history — GpsGate is the precedent — declare the descriptor without PositionHistory and implement GetPositionAsync(from, to, …) as a stub that throws ProviderCapabilityNotSupportedException. Callers check the catalog first; the stub is defense in depth.

History is the only capability a shipped PositionReader may stub out. Startup cross-checks every other declaration against the reader classes actually present in your assembly and throws on any mismatch — a declared capability with no backing reader, or an implemented reader the descriptor hides. A consequence worth knowing: a history-only provider (history but no real-time reads) is not currently representable — shipping a PositionReader requires declaring RealTimePositions.

Partial providers

A provider may ship without a DeviceReader or ConnectivityTester — some APIs simply have no device-catalog or ping-friendly operation. Omit the class and omit the capability from the descriptor; the two must agree. The pipeline then treats the gap as a declared limitation, not a failure:

  • the background device-sync, health-probe and position loops skip the operator quietly for the missing capability — no FAILED sync runs, no OFFLINE health marks, no recurring alerts;
  • the user-triggered surfaces (manual "sync now", the operator ping button) fail with PROVIDER_CAPABILITY_NOT_SUPPORTED, naming the provider.

For a catalog-less provider, devices are registered manually in the portal. Which DeviceTransporterVm field keys your provider — Identifier (numeric id), Serial (IMEI) or Name (plate/label) — is a per-provider convention you define; document it on the reader base class so operators know what to enter when creating devices by hand.

Shared semantics

  • Protocol property — return your ProtocolType enum value. The registries resolve readers keyed by this value; a mismatch with the enum or config spelling is caught at startup.
  • Init(CredentialTokenDto credential, CancellationToken ct) — called before every batch of calls: each sync cycle, each query, each ping. It receives the operator's decrypted credential (Uri, Username, Password, optional Key / Key2 / Token plus expirations — which field carries what is a per-provider convention you define). Init must:
    • build the HttpClient through ICredentialHttpClientFactorynever new HttpClient(); the factory applies the hardened named client (auto-redirect disabled, 30 s timeout) — and hand it to IHttpClientService.Init;
    • be cheap on repeat calls. If your provider requires a login round-trip, cache the session artifact in IProviderSessionStore (see §3). Init runs for every operator on every cycle; an unconditional login is the difference between N cheap in-memory hits and N provider logins per minute at fleet scale.
  • Readers are keyed-transient and stateless between operators. A new reader instance is constructed per lookup, so instance fields set in Init are safe. Anything shared across instances — the session store — must be thread-safe.
  • Errors must throw, never degrade to empty results. If your provider reports failures as HTTP 200 with an error payload (Wialon and Navixy both do), detect it and throw InvalidOperationException carrying the provider error code.

A swallowed error becomes a "successful" sync of 0 positions — the worst failure mode in this pipeline, because it silently stops position flow while Health stays green. The sync pipeline converts thrown exceptions into a FAILED sync run, a GpsOperatorPositionSyncFailed alert, and operator backoff.

IPositionReader

Task<PositionVm> GetDevicePositionAsync(DeviceTransporterVm device, CancellationToken ct);
Task<IEnumerable<PositionVm>> GetDevicePositionAsync(IEnumerable<DeviceTransporterVm> devices, CancellationToken ct);
Task<IEnumerable<PositionVm>> GetPositionAsync(DateTimeOffset from, DateTimeOffset to, DeviceTransporterVm device, CancellationToken ct);
  • The plural overload is the sync-loop hot path — prefer one batched provider call over per-device calls whenever the API allows it.
  • The from/to overload serves position history. Timestamps are UTC DateTimeOffset everywhere; convert at the provider boundary only.
  • Units contract for PositionVm: speed in km/h, mileage in km, temperature in °C. Verify what the provider actually emits against a live instance — do not guess conversions. (Traccar, for example, likely emits knots. An unverified conversion is worse than a documented raw value.)
  • Attributes: the five promoted fields — ignition, satellites, mileage, hourmeter, temperature — are strongly typed on AttributesVm; every other provider signal goes into the open Extra bag, built with Domain.Helpers.AttributesExtra. Never add a cross-service schema field for a provider-specific signal. Attribute naming on the wire is TrackHub's, not the provider's — remap vendor spellings at your boundary (CommandTrack's hobbsMeter becomes [JsonPropertyName("hobbsMeter")] on a property named Hourmeter).

IExternalDeviceReader

Task<DeviceVm> GetDeviceAsync(DeviceTransporterVm device, CancellationToken ct);
Task<IEnumerable<DeviceVm>> GetDevicesAsync(IEnumerable<DeviceTransporterVm> devices, CancellationToken ct);
Task<IEnumerable<DeviceVm>> GetDevicesAsync(CancellationToken ct);

The parameterless overload returns the provider's full device catalog. It feeds the device-sync loop that reconciles Manager's catalog, guarded per-operator by IOperatorSyncLock and cached by IDeviceCatalogCache.

IConnectivityTester

Task Ping(CredentialTokenDto credential, CancellationToken ct);

Ping must perform a real provider round-tripInit plus one minimal authenticated call (list one device, fetch server info). With session caching, Init alone may be a pure in-memory hit, which would turn the health monitor into a no-op reporting HEALTHY while the provider is down.

Success and failure of every ping and device-sync attempt is recorded as a health observation (DEVICE_SYNC, HEALTHY/OFFLINE) that drives the operator Health status in the portal.


2. The three alignment points

Registration (ProtocolRegistrationExtensions, called from AddCommonContext) resolves everything by convention. Three things must line up — names are matched case-insensitively:

# Where What
1 TrackHubCommonCommon.Domain/Enums/ProtocolType.cs The enum value (e.g. Wialon = 8)
2 The provider assembly AssemblyName + RootNamespace = TrackHub.Router.Infrastructure.{Protocol}; reader classes named exactly DeviceReader / PositionReader / ConnectivityTester with Protocol returning the enum value; one ProviderDescriptor whose capabilities match the shipped readers (§1)
3 AppSettings:Protocols (Web and SyncWorker appsettings.json) The enum spelling listed

Any mismatch fails at startup with a message naming the protocol: unknown enum value, missing assembly, an assembly with no matching reader types, a missing descriptor, or a capability declaration that does not match the readers. An operator whose ProtocolTypeId has no registered reader gets ProtocolNotSupportedException naming the protocol, not a masked error.

Casing may drift between config, enum and namespace (GeoTabGeotab) — resolution is case-insensitive — but keep new providers consistent: folder, project, assembly and namespace should all carry the same name as the enum value.

Mettax = 10 is reserved in the enum with no provider assembly: ProtocolRegistrationExtensions.ReservedDescriptors supplies its None-capability placeholder so the capability catalog stays complete. Configuring it throws at startup until someone builds the project (and removes it from the reserved list).


3. Session and token caching

The singleton IProviderSessionStore (registered in AddCommonContext) keeps one session artifact per credential, so repeated Init calls do not re-authenticate:

bool TryGet(CredentialTokenDto credential, out string session);
void Set(CredentialTokenDto credential, string session, TimeSpan timeToLive, bool sliding = true);
void Invalidate(Guid credentialId);

Entries are fingerprinted by the full credential value, so a rotated password or token is automatically a cache miss — never a stale session.

Pick the pattern matching your provider's auth model:

Auth model Pattern Example
Static header (Basic auth, permanent API token) No caching needed — Init is already free Traccar, Samsara, Flespi, GpsGate
Login → inactivity-timeout session TryGet in Init; on a miss, log in and Set with a sliding TTL below the provider's inactivity timeout. On an invalid-session error mid-call: Invalidate, re-login, retry once, then throw Wialon (sid, 4 min sliding), Navixy (hash, 30 min sliding)
Vendor SDK with session id Cache the SDK session id. On a hit, construct the SDK client with password and session id so the SDK self-heals an expired session; persist the current id after successful calls Geotab
Bearer token with absolute expiry The durable copy already lives on the Manager credential (ICredentialWriter.UpdateTokenAsync); Set with a non-sliding TTL of expiry − now − margin as an in-process fast path CommandTrack, Protrack

Rules regardless of pattern:

  • The retry-once-on-invalid-session logic lives in the provider base class (WialonReaderBase.PostAsync, NavixyReaderBase.PostNavixyAsync) so readers stay oblivious. Copy that shape.
  • Never let a cached session weaken error semantics: a second failure after re-login must throw, and Ping must still round-trip (§1).
  • The store is in-process, matching the single-instance SyncWorker deployment — the same assumption as IOperatorSyncLock and ExecutionIntervalManager. If the worker is ever scaled out, session caching degrades gracefully (each instance logs in once); there is no correctness issue.

4. Step by step

  1. Enum value — add {Protocol} = N to ProtocolType in TrackHubCommon (src/Common.Domain/Enums/ProtocolType.cs), then repack and bump the Common packages across consumers. This is additive and non-breaking. See Common Library.

  2. Project — create src/Infrastructure/{Protocol}/{Protocol}.csproj:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <RootNamespace>TrackHub.Router.Infrastructure.{Protocol}</RootNamespace>
        <AssemblyName>TrackHub.Router.Infrastructure.{Protocol}</AssemblyName>
      </PropertyGroup>
      <ItemGroup>
        <ProjectReference Include="..\..\Domain\Domain.csproj" />
      </ItemGroup>
    </Project>

    TargetFramework, nullable and warnings-as-errors come from Directory.Build.props.

  3. Implement{Protocol}ReaderBase (Init, auth, session caching, shared HTTP helpers), DeviceReader, PositionReader, ConnectivityTester, Models/ (provider DTOs, internal) and Mappers/ (static extension methods to DeviceVm / PositionVm — mapping is manual, there is no AutoMapper).

  4. Declare capabilities — add a ProviderDescriptor : IProviderDescriptor class to the assembly's root namespace (protocol, display name, capabilities). Startup cross-checks the declaration against your reader classes. There is no portal step: the operator dialog's protocol select reads the providerCapabilities query.

  5. Wire the build — add the project to TrackHub.Router.slnx and a ProjectReference in src/Infrastructure/Common/Common.csproj. That reference is what puts your DLL in the Web and SyncWorker output.

  6. Activate — add the protocol name to AppSettings:Protocols in both src/Web/appsettings.json and src/SyncWorker/appsettings.json. In deployed environments this is the AppSettings__Protocols__N environment variable — see Deployment and Operations.

  7. Tests — add {Protocol}DeviceReaderTests and {Protocol}PositionReaderTests in tests/Intfrastructure.UnitTests, deriving from DeviceReaderTestsBase<T> / PositionReaderTestsBase<T> (mocks for ICredentialHttpClientFactory, IHttpClientService and IProviderSessionStore are provided). Cover happy-path mapping, empty results, provider-error payloads (which must throw), and — if you cache sessions — the re-login-and-retry path. A registration case in ProtocolRegistrationExtensionsTests pins the keyed wiring; EveryProtocolTypeValue_IsEitherRegistrableOrReserved fails automatically if the enum value has neither an assembly nor a reserved placeholder.

  8. Verifydotnet build TrackHub.Router.slnx && dotnet test TrackHub.Router.slnx, then start the Web project; startup throws if any alignment point is off. Finally, create an operator with the new protocol and use Ping and Sync now in the portal against a live account.

No Security or Manager permission work is needed. Provider calls go outward with the operator's credential, not through the service-client grant system.


5. Runtime behaviour you inherit

Nothing here needs building — it comes with the pipeline:

  • Concurrency — accounts sync concurrently under the global operator gate (AppSettings:MaxConcurrentOperatorSyncs, default 10). Two same-protocol operators in one scope get separate reader and HttpClientService instances.
  • Failure handling — a thrown provider error produces a FAILED sync run carrying your message, a GpsOperatorPositionSyncFailed alert, and exponential backoff (1 → 30 min) until the first success.
  • Device catalog cache — 60 s (AppSettings:DeviceCatalogCacheSeconds), invalidated by device sync, so GetDevicesAsync is not called every position cycle.
  • Health — every ping and device-sync attempt records a HEALTHY/OFFLINE observation.
  • Rate limiting and authorization — manual sync and ping are authorized and rate-limited at the command layer (Resources.Credentials / Actions.Custom); your reader never checks callers.
  • HTTP hardening — the shared named client disables auto-redirect (an operator-configured base URL cannot 302 the Router inward) and enforces the 30 s timeout.

6. Documentation upkeep

When the provider lands:

  • update the supported-protocols table on Router;
  • update system-context/architecture.md if you introduced a new pattern;
  • regenerate the machine-generated catalogs with tools/Tools.McpExtractor.

Clone this wiki locally