-
Notifications
You must be signed in to change notification settings - Fork 1
Adding a Provider
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
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.
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.
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.
-
Protocolproperty — return yourProtocolTypeenum 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, optionalKey/Key2/Tokenplus expirations — which field carries what is a per-provider convention you define).Initmust:- build the
HttpClientthroughICredentialHttpClientFactory— nevernew HttpClient(); the factory applies the hardened named client (auto-redirect disabled, 30 s timeout) — and hand it toIHttpClientService.Init; -
be cheap on repeat calls. If your provider requires a login round-trip, cache the session artifact in
IProviderSessionStore(see §3).Initruns 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.
- build the
-
Readers are keyed-transient and stateless between operators. A new reader instance is constructed per lookup, so instance fields set in
Initare 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
InvalidOperationExceptioncarrying 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
GpsOperatorPositionSyncFailedalert, and operator backoff.
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/tooverload serves position history. Timestamps are UTCDateTimeOffseteverywhere; 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 openExtrabag, built withDomain.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'shobbsMeterbecomes[JsonPropertyName("hobbsMeter")]on a property namedHourmeter).
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.
Task Ping(CredentialTokenDto credential, CancellationToken ct);Ping must perform a real provider round-trip — Init 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.
Registration (ProtocolRegistrationExtensions, called from AddCommonContext) resolves everything by convention. Three things must line up — names are matched case-insensitively:
| # | Where | What |
|---|---|---|
| 1 |
TrackHubCommon → Common.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 (
GeoTab↔Geotab) — 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).
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
Pingmust still round-trip (§1). - The store is in-process, matching the single-instance SyncWorker deployment — the same assumption as
IOperatorSyncLockandExecutionIntervalManager. If the worker is ever scaled out, session caching degrades gracefully (each instance logs in once); there is no correctness issue.
-
Enum value — add
{Protocol} = NtoProtocolTypein 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. -
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 fromDirectory.Build.props. -
Implement —
{Protocol}ReaderBase(Init, auth, session caching, shared HTTP helpers),DeviceReader,PositionReader,ConnectivityTester,Models/(provider DTOs,internal) andMappers/(static extension methods toDeviceVm/PositionVm— mapping is manual, there is no AutoMapper). -
Declare capabilities — add a
ProviderDescriptor : IProviderDescriptorclass 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 theproviderCapabilitiesquery. -
Wire the build — add the project to
TrackHub.Router.slnxand aProjectReferenceinsrc/Infrastructure/Common/Common.csproj. That reference is what puts your DLL in the Web and SyncWorker output. -
Activate — add the protocol name to
AppSettings:Protocolsin bothsrc/Web/appsettings.jsonandsrc/SyncWorker/appsettings.json. In deployed environments this is theAppSettings__Protocols__Nenvironment variable — see Deployment and Operations. -
Tests — add
{Protocol}DeviceReaderTestsand{Protocol}PositionReaderTestsintests/Intfrastructure.UnitTests, deriving fromDeviceReaderTestsBase<T>/PositionReaderTestsBase<T>(mocks forICredentialHttpClientFactory,IHttpClientServiceandIProviderSessionStoreare 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 inProtocolRegistrationExtensionsTestspins the keyed wiring;EveryProtocolTypeValue_IsEitherRegistrableOrReservedfails automatically if the enum value has neither an assembly nor a reserved placeholder. -
Verify —
dotnet 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.
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 andHttpClientServiceinstances. -
Failure handling — a thrown provider error produces a FAILED sync run carrying your message, a
GpsOperatorPositionSyncFailedalert, and exponential backoff (1 → 30 min) until the first success. -
Device catalog cache — 60 s (
AppSettings:DeviceCatalogCacheSeconds), invalidated by device sync, soGetDevicesAsyncis 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.
When the provider lands:
- update the supported-protocols table on Router;
- update
system-context/architecture.mdif you introduced a new pattern; - regenerate the machine-generated catalogs with
tools/Tools.McpExtractor.
Platform
- Architecture
- Technology
- Inter-Service Communication
- Database
- Security and Identity
- User Permissions Overview
Services
- Manager
- Telemetry
- Router
- Adding a Provider
- Geofencing
- Trip Management
- Reporting
- Common Library
- Frontend
Engineering
User docs
- User Guide (ships in the app)