diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index d1df76efb..82649bcf0 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -66,6 +66,8 @@ jobs:
dockerfile: Web/Resgrid.Web.Tts/Dockerfile
- image: resgridllc/resgridworkersconsole
dockerfile: Workers/Resgrid.Workers.Console/Dockerfile
+ - image: resgridllc/resgridtrackergateway
+ dockerfile: Workers/Resgrid.TrackerGateway/Dockerfile
steps:
- uses: actions/checkout@v4
@@ -145,6 +147,7 @@ jobs:
.replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '')
.replace(/^(#+\s*)(Pull Request Description|PR Description)\s*$/gim, '$1Summary')
.replace(/Pull Request Description|PR Description/gi, 'Summary')
+ .replace(/\bPR\b/g, 'Release')
.trim() || 'No release notes provided.';
fs.writeFileSync('release_notes.md', body);
} else {
diff --git a/Core/Resgrid.Config/ChatConfig.cs b/Core/Resgrid.Config/ChatConfig.cs
index eefa92a41..95a0b2498 100644
--- a/Core/Resgrid.Config/ChatConfig.cs
+++ b/Core/Resgrid.Config/ChatConfig.cs
@@ -14,6 +14,8 @@ public static class ChatConfig
public static string NovuUnitApnsProviderId = "unit-apns";
public static string NovuResponderFcmProviderId = "respond-firebase-cloud-messaging";
public static string NovuResponderApnsProviderId = "respond-apns";
+ public static string NovuICFcmProviderId = "ic-firebase-cloud-messaging";
+ public static string NovuICApnsProviderId = "ic-apns";
public static string NovuDispatchUnitWorkflowId = "unit-dispatch";
public static string NovuDispatchUserWorkflowId = "user-dispatch";
public static string NovuMessageUserWorkflowId = "user-message";
diff --git a/Core/Resgrid.Config/UnitTrackingConfig.cs b/Core/Resgrid.Config/UnitTrackingConfig.cs
index dbc8ad946..19b6646e8 100644
--- a/Core/Resgrid.Config/UnitTrackingConfig.cs
+++ b/Core/Resgrid.Config/UnitTrackingConfig.cs
@@ -14,6 +14,10 @@ public static class UnitTrackingConfig
public static int DefaultLocationRetentionDays = 90;
public static int MinimumLocationRetentionDays = 1;
public static int MaximumLocationRetentionDays = 3650;
+ public static bool LocationRetentionWorkerEnabled = false;
+ public static int LocationRetentionBatchSize = 1000;
+ public static int LocationRetentionMaxRowsPerRun = 100000;
+ public static int LocationRetentionHourUtc = 4;
public static int PerDeviceRequestsPerMinute = 120;
public static int PerDeviceRecordsPerMinute = 1200;
public static int UnknownEndpointRequestsPerMinute = 30;
@@ -39,8 +43,14 @@ public static class UnitTrackingConfig
public static int TeltonikaTcpPort = 5027;
public static int TeltonikaUdpPort = 5027;
public static bool EnableQueclink = false;
+ public static bool EnableQueclinkTcp = true;
+ public static bool EnableQueclinkUdp = false;
public static bool EnableGt06 = false;
+ public static bool EnableGt06Tcp = true;
+ public static bool EnableGt06Udp = false;
public static bool EnableTeltonika = false;
+ public static bool EnableTeltonikaTcp = true;
+ public static bool EnableTeltonikaUdp = false;
public static bool RawDiagnosticCaptureEnabled = false;
}
}
diff --git a/Core/Resgrid.Model/Providers/Models/INovuProvider.cs b/Core/Resgrid.Model/Providers/Models/INovuProvider.cs
index 452c120fd..7be64f3c0 100644
--- a/Core/Resgrid.Model/Providers/Models/INovuProvider.cs
+++ b/Core/Resgrid.Model/Providers/Models/INovuProvider.cs
@@ -19,6 +19,19 @@ public interface INovuProvider
/// True if the subscriber was created successfully; otherwise, false.
Task CreateUserSubscriber(string userId, string code, int departmentId, string email, string firstName, string lastName);
+ ///
+ /// Creates a Novu subscriber for an IC (Incident Command) app user. The IC app uses a distinct subscriber
+ /// id ({code}_IC_User_{userId}) so its Novu inbox is separate from the Responder app's.
+ ///
+ /// The unique identifier of the user.
+ /// The department code prefix.
+ /// The department the user belongs to.
+ /// The user's email address.
+ /// The user's first name.
+ /// The user's last name.
+ /// True if the subscriber was created successfully; otherwise, false.
+ Task CreateICUserSubscriber(string userId, string code, int departmentId, string email, string firstName, string lastName);
+
///
/// Creates a Novu subscriber for a unit (device or group).
///
@@ -66,6 +79,24 @@ public interface INovuProvider
/// True if the token was updated successfully; otherwise, false.
Task UpdateUserSubscriberApns(string userId, string code, string token);
+ ///
+ /// Updates the Firebase Cloud Messaging (FCM) token for an IC app user subscriber.
+ ///
+ /// The unique identifier of the user.
+ /// The department code prefix.
+ /// The FCM token to associate with the user.
+ /// True if the token was updated successfully; otherwise, false.
+ Task UpdateICUserSubscriberFcm(string userId, string code, string token);
+
+ ///
+ /// Updates the Apple Push Notification Service (APNS) token for an IC app user subscriber.
+ ///
+ /// The unique identifier of the user.
+ /// The department code prefix.
+ /// The APNS token to associate with the user.
+ /// True if the token was updated successfully; otherwise, false.
+ Task UpdateICUserSubscriberApns(string userId, string code, string token);
+
///
/// Sends a dispatch notification to a unit.
///
@@ -94,4 +125,16 @@ Task SendUnitDispatch(string title, string body, int unitId, string depCod
Task SendUserMessage(string title, string body, string userId, string depCode, string eventCode, string type);
Task SendUserNotification(string title, string body, string userId, string depCode, string eventCode, string type);
+
+ ///
+ /// Sends a notification to an IC app user subscriber ({depCode}_IC_User_{userId}) via the user-notification workflow.
+ ///
+ /// The notification title.
+ /// The notification body content.
+ /// The unique identifier of the user to notify.
+ /// The department code.
+ /// The event code associated with the notification.
+ /// The type of notification.
+ /// True if the notification was sent successfully; otherwise, false.
+ Task SendICUserNotification(string title, string body, string userId, string depCode, string eventCode, string type);
}
diff --git a/Core/Resgrid.Model/PushUri.cs b/Core/Resgrid.Model/PushUri.cs
index 6414e2d26..bf59db2c3 100644
--- a/Core/Resgrid.Model/PushUri.cs
+++ b/Core/Resgrid.Model/PushUri.cs
@@ -51,6 +51,14 @@ public class PushUri : IEntity
[ProtoMember(7)]
public int DepartmentId { get; set; }
+ ///
+ /// Source app of the registration (e.g. "IC" for the Incident Command app). Routes the Novu push
+ /// credential update to the app-specific subscriber; null/empty means the default Responder app.
+ ///
+ [NotMapped]
+ [ProtoMember(10)]
+ public string Source { get; set; }
+
[Required]
[ProtoMember(6)]
public DateTime CreatedOn { get; set; }
diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs
new file mode 100644
index 000000000..26f12c3ed
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IUnitLocationRetentionRepository.cs
@@ -0,0 +1,15 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IUnitLocationRetentionRepository
+ {
+ Task DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
index ba3dd0cc6..3e9e401bc 100644
--- a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
@@ -1,5 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
+using System;
+using System.Threading;
namespace Resgrid.Model.Repositories
{
@@ -12,5 +14,10 @@ public interface IUnitLocationsDocRepository
Task GetByOldIdAsync(string id);
Task InsertAsync(UnitsLocation location);
Task UpdateAsync(UnitsLocation location);
+ Task DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs
index 94d537f6c..9bdea7042 100644
--- a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs
@@ -1,4 +1,6 @@
using System.Threading.Tasks;
+using System;
+using System.Threading;
namespace Resgrid.Model.Repositories
{
@@ -7,5 +9,10 @@ public interface IUnitLocationsMongoRepository
Task EnsureIndexesAsync();
Task InsertAsync(UnitsLocation location);
Task UpdateAsync(UnitsLocation location);
+ Task DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/Resgrid.Model.csproj b/Core/Resgrid.Model/Resgrid.Model.csproj
index 152dd04a0..9ef49185e 100644
--- a/Core/Resgrid.Model/Resgrid.Model.csproj
+++ b/Core/Resgrid.Model/Resgrid.Model.csproj
@@ -4,6 +4,7 @@
Debug;Release;Docker
+
Audio\Emergency_call.caf
@@ -92,4 +93,4 @@
-
\ No newline at end of file
+
diff --git a/Core/Resgrid.Model/Services/ICommunicationService.cs b/Core/Resgrid.Model/Services/ICommunicationService.cs
index a0bfe6f27..078839161 100644
--- a/Core/Resgrid.Model/Services/ICommunicationService.cs
+++ b/Core/Resgrid.Model/Services/ICommunicationService.cs
@@ -66,9 +66,10 @@ Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string
/// The department number.
/// The title.
/// The profile.
+ /// When true, the push goes to the user's IC app subscriber (separate Novu inbox) instead of the Responder one.
/// Task<System.Boolean>.
- Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department,
- string title = "Notification", UserProfile profile = null);
+ Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department,
+ string title = "Notification", UserProfile profile = null, bool sendToICApp = false);
///
/// Sends the chat.
diff --git a/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs
index 7e8e18576..27b64ed90 100644
--- a/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs
+++ b/Core/Resgrid.Model/Services/IIncidentCommandNotificationService.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -30,5 +31,12 @@ Task NotifyLaneLeadChangedAsync(int departmentId, int callId, string laneName, b
/// Notifies every active user and unit on the incident (plus both commanders) that command was transferred.
Task NotifyCommandTransferredAsync(IncidentCommand command, string fromUserId, string toUserId, CancellationToken cancellationToken = default(CancellationToken));
+
+ ///
+ /// Sends a free-form message from a department member directly to specific command users (e.g. the
+ /// incident commander and deputies). The sender's display name is prepended to the body so recipients
+ /// know who it came from on every channel.
+ ///
+ Task SendMessageToCommandAsync(int departmentId, string senderUserId, string title, string body, IEnumerable recipientUserIds, CancellationToken cancellationToken = default(CancellationToken));
}
}
diff --git a/Core/Resgrid.Model/Services/IPushService.cs b/Core/Resgrid.Model/Services/IPushService.cs
index 6c68d441d..c426879c7 100644
--- a/Core/Resgrid.Model/Services/IPushService.cs
+++ b/Core/Resgrid.Model/Services/IPushService.cs
@@ -12,6 +12,7 @@ public interface IPushService
Task UnRegister(PushUri pushUri);
void UnRegisterNotificationOnly(PushUri pushUri);
Task PushNotification(StandardPushMessage message, string userId, UserProfile profile = null);
+ Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null);
Task RegisterUnit(PushUri pushUri);
Task UnRegisterUnit(PushUri pushUri);
Task PushChat(StandardPushMessage message, string userId, UserProfile profile = null);
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs
index fcbd37ede..c39f0d42e 100644
--- a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs
+++ b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
@@ -11,5 +12,10 @@ Task AcceptAsync(
AuthenticatedTrackingSource source,
IReadOnlyCollection positions,
CancellationToken cancellationToken = default);
+
+ Task AcceptHeartbeatAsync(
+ AuthenticatedTrackingSource source,
+ DateTime receivedOnUtc,
+ CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json b/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json
new file mode 100644
index 000000000..dbb3e7fe9
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/Catalog/unit-tracking-catalog.json
@@ -0,0 +1,448 @@
+{
+ "profiles": [
+ {
+ "key": "generic-https",
+ "manufacturerKey": "generic",
+ "manufacturerName": "Generic",
+ "model": "Resgrid JSON",
+ "transportType": "NativeHttps",
+ "protocolKey": "resgrid-json",
+ "payloadAdapterKey": "resgrid-json-v1",
+ "decoderVariant": "resgrid-json-v1",
+ "supportedTransports": [ "Https" ],
+ "certifiedTransports": [ "Https" ],
+ "certificationStatus": "Certified",
+ "protocolDocumentVersion": "resgrid-json-v1",
+ "identifierRequired": false,
+ "isSelectable": true,
+ "supportedAuthModes": [ "Bearer", "Basic", "CustomHeader", "CapabilityPath" ],
+ "setupSummary": "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.",
+ "retryExpectation": "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery."
+ },
+ {
+ "key": "traccar-forwarder",
+ "manufacturerKey": "traccar",
+ "manufacturerName": "Traccar",
+ "model": "Position Forwarding",
+ "transportType": "ProtocolGateway",
+ "protocolKey": "traccar",
+ "payloadAdapterKey": "traccar-json-v1",
+ "decoderVariant": "traccar-json-v1",
+ "supportedTransports": [ "Https" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "6.14.5",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [ "Bearer", "Basic", "CustomHeader", "CapabilityPath" ],
+ "setupSummary": "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.",
+ "retryExpectation": "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures."
+ },
+ {
+ "key": "teltonika-fmc920",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC920",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26",
+ "ioMapKey": "teltonika-fmb-03-29-common",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmm920",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM920",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26",
+ "ioMapKey": "teltonika-fmb-03-29-common",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmc130",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC130",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26",
+ "ioMapKey": "teltonika-fmb-03-29-common",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmm130",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM130",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26",
+ "ioMapKey": "teltonika-fmb-03-29-common",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmc003",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC003",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Teltonika Data Sending Protocols; reviewed 2026-07-26",
+ "ioMapKey": "teltonika-fmb-03-29-common",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the device to send Codec8 or Codec8 Extended AVL data to the Resgrid Teltonika listener using its IMEI identifier.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "queclink-gv57mg",
+ "manufacturerKey": "queclink",
+ "manufacturerName": "Queclink",
+ "model": "GV57MG",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "queclink-attrack",
+ "decoderVariant": "gl200-text-bounded",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the GV57MG to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.",
+ "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response."
+ },
+ {
+ "key": "queclink-gv350mg",
+ "manufacturerKey": "queclink",
+ "manufacturerName": "Queclink",
+ "model": "GV350MG",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "queclink-attrack",
+ "decoderVariant": "gl200-text-bounded",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the GV350MG to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.",
+ "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response."
+ },
+ {
+ "key": "queclink-gv500ma",
+ "manufacturerKey": "queclink",
+ "manufacturerName": "Queclink",
+ "model": "GV500MA",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "queclink-attrack",
+ "decoderVariant": "gl200-text-bounded",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Traccar v6.14.5 gl200 behavior at 5c5e710d; model certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the GV500MA to send bounded Queclink @Track text reports over TCP using its 15-digit IMEI identifier.",
+ "retryExpectation": "Retain and retry position reports until delivery succeeds according to the configured Queclink acknowledgement mode; heartbeat reports require the matching Resgrid SACK response."
+ },
+ {
+ "key": "jimi-vl103m",
+ "manufacturerKey": "jimi",
+ "manufacturerName": "Jimi IoT",
+ "model": "VL103M",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "gt06",
+ "decoderVariant": "gt06-vl103-bounded",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Traccar v6.14.5 gt06 behavior at 5c5e710d; model certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the VL103M to send the bounded GT06 login, heartbeat/status, and location messages over TCP using its 15-digit IMEI identifier.",
+ "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial."
+ },
+ {
+ "key": "jimi-jm-vl03",
+ "manufacturerKey": "jimi",
+ "manufacturerName": "Jimi IoT",
+ "model": "JM-VL03",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "gt06",
+ "decoderVariant": "gt06-jm-vl03-a0-bounded",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "Traccar v6.14.5 gt06 behavior at 5c5e710d; model certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Configure the JM-VL03 to send the bounded GT06 login, heartbeat/status, and JM-VL03 A0 location messages over TCP using its 15-digit IMEI identifier.",
+ "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial."
+ },
+ {
+ "key": "teltonika-fmm003",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM003",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmc125",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC125",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmm125",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM125",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmc150",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC150",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmm150",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM150",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmc230",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMC230",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "teltonika-fmm230",
+ "manufacturerKey": "teltonika",
+ "manufacturerName": "Teltonika",
+ "model": "FMM230",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "teltonika-codec8",
+ "decoderVariant": "codec8-or-8e",
+ "supportedTransports": [ "Tcp", "Udp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing Teltonika Codec8 TCP and UDP listeners; do not enable until model and firmware certification is complete.",
+ "retryExpectation": "Retain records until Resgrid returns an accepted-record count matching the transmitted AVL record count."
+ },
+ {
+ "key": "jimi-jm-vl01",
+ "manufacturerKey": "jimi",
+ "manufacturerName": "Jimi IoT",
+ "model": "JM-VL01",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "gt06",
+ "decoderVariant": "gt06-jm-vl01-unverified",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.",
+ "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial."
+ },
+ {
+ "key": "jimi-jm-vl02",
+ "manufacturerKey": "jimi",
+ "manufacturerName": "Jimi IoT",
+ "model": "JM-VL02",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "gt06",
+ "decoderVariant": "gt06-jm-vl02-unverified",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.",
+ "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial."
+ },
+ {
+ "key": "jimi-jm-vl04",
+ "manufacturerKey": "jimi",
+ "manufacturerName": "Jimi IoT",
+ "model": "JM-VL04",
+ "transportType": "NativeTcpUdp",
+ "protocolKey": "gt06",
+ "decoderVariant": "gt06-jm-vl04-unverified",
+ "supportedTransports": [ "Tcp" ],
+ "certifiedTransports": [],
+ "certificationStatus": "Candidate",
+ "protocolDocumentVersion": "WP11 family placement; exact model protocol and firmware certification pending",
+ "identifierRequired": true,
+ "isSelectable": false,
+ "supportedAuthModes": [],
+ "setupSummary": "Candidate family profile for the existing bounded GT06 TCP listener; do not enable until its exact message layouts and firmware are certified.",
+ "retryExpectation": "Retain and retry each supported message until the Resgrid listener returns a CRC-protected acknowledgement with the matching protocol number and serial."
+ }
+ ],
+ "ioMaps": [
+ {
+ "key": "teltonika-fmb-03-29-common",
+ "protocolKey": "teltonika-codec8",
+ "protocolDocumentVersion": "FMB.Ver.03.29.00 and newer; reviewed 2026-07-26",
+ "mappings": [
+ {
+ "avlId": 182,
+ "valueBytes": 2,
+ "target": "Hdop",
+ "multiplier": 0.1,
+ "minimumRawValue": 0,
+ "maximumRawValue": 500
+ },
+ {
+ "avlId": 66,
+ "valueBytes": 2,
+ "target": "ExternalPowerVolts",
+ "multiplier": 0.001,
+ "minimumRawValue": 0,
+ "maximumRawValue": 65535
+ },
+ {
+ "avlId": 239,
+ "valueBytes": 1,
+ "target": "Ignition",
+ "multiplier": 1,
+ "minimumRawValue": 0,
+ "maximumRawValue": 1
+ },
+ {
+ "avlId": 240,
+ "valueBytes": 1,
+ "target": "IsMoving",
+ "multiplier": 1,
+ "minimumRawValue": 0,
+ "maximumRawValue": 1
+ }
+ ]
+ }
+ ]
+}
diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs
new file mode 100644
index 000000000..6acba3ecf
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalog.cs
@@ -0,0 +1,496 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace Resgrid.Model.Tracking
+{
+ public static class UnitTrackingCatalog
+ {
+ private const string CatalogResourceSuffix =
+ ".Tracking.Catalog.unit-tracking-catalog.json";
+
+ private static readonly Lazy Catalog =
+ new(LoadAndValidate);
+
+ public static IReadOnlyCollection Profiles =>
+ Catalog.Value.Profiles;
+
+ public static IReadOnlyCollection IoMaps =>
+ Catalog.Value.IoMaps;
+
+ public static UnitTrackingCatalogProfile GetProfile(string profileKey)
+ {
+ if (string.IsNullOrWhiteSpace(profileKey))
+ return null;
+
+ return Profiles.FirstOrDefault(profile =>
+ string.Equals(
+ profile.Key,
+ profileKey.Trim(),
+ StringComparison.OrdinalIgnoreCase));
+ }
+
+ public static UnitTrackingIoMap GetIoMap(string ioMapKey)
+ {
+ if (string.IsNullOrWhiteSpace(ioMapKey))
+ return null;
+
+ return IoMaps.FirstOrDefault(ioMap =>
+ string.Equals(
+ ioMap.Key,
+ ioMapKey.Trim(),
+ StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static UnitTrackingCatalogDocument LoadAndValidate()
+ {
+ var assembly = typeof(UnitTrackingCatalog).Assembly;
+ var resourceName = assembly
+ .GetManifestResourceNames()
+ .SingleOrDefault(name =>
+ name.EndsWith(
+ CatalogResourceSuffix,
+ StringComparison.Ordinal));
+ if (resourceName == null)
+ {
+ throw new InvalidOperationException(
+ "The embedded unit tracking catalog was not found.");
+ }
+
+ using var stream = assembly.GetManifestResourceStream(
+ resourceName);
+ if (stream == null)
+ {
+ throw new InvalidOperationException(
+ "The embedded unit tracking catalog could not be opened.");
+ }
+
+ using var reader = new StreamReader(stream);
+ UnitTrackingCatalogDocument document;
+ try
+ {
+ document = JsonConvert.DeserializeObject<
+ UnitTrackingCatalogDocument>(
+ reader.ReadToEnd(),
+ new JsonSerializerSettings
+ {
+ MissingMemberHandling =
+ MissingMemberHandling.Error,
+ Converters =
+ {
+ new StringEnumConverter()
+ }
+ });
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ "The embedded unit tracking catalog is invalid.",
+ ex);
+ }
+
+ Validate(document);
+ document.Profiles =
+ document.Profiles.ToList().AsReadOnly();
+ document.IoMaps =
+ document.IoMaps.ToList().AsReadOnly();
+ foreach (var profile in document.Profiles)
+ {
+ profile.SupportedTransports =
+ profile.SupportedTransports.ToList().AsReadOnly();
+ profile.CertifiedTransports =
+ profile.CertifiedTransports.ToList().AsReadOnly();
+ profile.SupportedAuthModes =
+ profile.SupportedAuthModes.ToList().AsReadOnly();
+ }
+ foreach (var ioMap in document.IoMaps)
+ {
+ ioMap.Mappings =
+ ioMap.Mappings.ToList().AsReadOnly();
+ }
+
+ return document;
+ }
+
+ private static void Validate(
+ UnitTrackingCatalogDocument document)
+ {
+ if (document?.Profiles == null ||
+ document.Profiles.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "The unit tracking catalog must contain profiles.");
+ }
+ if (document.IoMaps == null)
+ {
+ throw new InvalidOperationException(
+ "The unit tracking catalog must contain an ioMaps collection.");
+ }
+
+ ValidateUniqueKeys(
+ document.Profiles.Select(profile => profile?.Key),
+ "profile");
+ ValidateUniqueKeys(
+ document.IoMaps.Select(ioMap => ioMap?.Key),
+ "I/O map");
+
+ foreach (var ioMap in document.IoMaps)
+ ValidateIoMap(ioMap);
+ foreach (var profile in document.Profiles)
+ ValidateProfile(profile, document.IoMaps);
+ }
+
+ private static void ValidateProfile(
+ UnitTrackingCatalogProfile profile,
+ IReadOnlyCollection ioMaps)
+ {
+ if (profile == null)
+ throw new InvalidOperationException(
+ "The unit tracking catalog contains a null profile.");
+
+ RequireText(profile.Key, "profile key");
+ RequireCanonicalKey(profile.Key, "profile key");
+ RequireText(
+ profile.ManufacturerKey,
+ $"manufacturer key for '{profile.Key}'");
+ RequireCanonicalKey(
+ profile.ManufacturerKey,
+ $"manufacturer key for '{profile.Key}'");
+ RequireText(
+ profile.ManufacturerName,
+ $"manufacturer name for '{profile.Key}'");
+ RequireText(
+ profile.Model,
+ $"model for '{profile.Key}'");
+ RequireText(
+ profile.ProtocolKey,
+ $"protocol key for '{profile.Key}'");
+ RequireCanonicalKey(
+ profile.ProtocolKey,
+ $"protocol key for '{profile.Key}'");
+ RequireText(
+ profile.DecoderVariant,
+ $"decoder variant for '{profile.Key}'");
+ RequireCanonicalKey(
+ profile.DecoderVariant,
+ $"decoder variant for '{profile.Key}'");
+ RequireText(
+ profile.ProtocolDocumentVersion,
+ $"protocol document version for '{profile.Key}'");
+ RequireText(
+ profile.SetupSummary,
+ $"setup summary for '{profile.Key}'");
+ RequireText(
+ profile.RetryExpectation,
+ $"retry expectation for '{profile.Key}'");
+
+ if (!Enum.IsDefined(profile.TransportType) ||
+ profile.TransportType ==
+ UnitTrackingTransportType.Unknown)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' has an invalid transport type.");
+ }
+ if (!Enum.IsDefined(profile.CertificationStatus) ||
+ profile.CertificationStatus ==
+ UnitTrackingCertificationStatus.Unknown)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' has an invalid certification status.");
+ }
+ if (profile.SupportedTransports == null ||
+ profile.SupportedTransports.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' must declare supported transports.");
+ }
+ if (profile.CertifiedTransports == null ||
+ profile.SupportedAuthModes == null)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' contains a null collection.");
+ }
+ if (profile.SupportedAuthModes.Any(mode =>
+ !Enum.IsDefined(mode) ||
+ mode == UnitTrackingAuthMode.Unknown) ||
+ profile.SupportedAuthModes.Distinct().Count() !=
+ profile.SupportedAuthModes.Count)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' declares an invalid authentication mode.");
+ }
+ if (!string.IsNullOrWhiteSpace(
+ profile.PayloadAdapterKey))
+ {
+ RequireCanonicalKey(
+ profile.PayloadAdapterKey,
+ $"payload adapter key for '{profile.Key}'");
+ }
+
+ ValidateTransports(
+ profile.Key,
+ profile.SupportedTransports);
+ ValidateTransports(
+ profile.Key,
+ profile.CertifiedTransports);
+ if (profile.CertifiedTransports.Any(transport =>
+ !profile.SupportedTransports.Contains(
+ transport,
+ StringComparer.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' certifies an unsupported transport.");
+ }
+ if (profile.CertificationStatus ==
+ UnitTrackingCertificationStatus.Certified &&
+ profile.CertifiedTransports.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Certified profile '{profile.Key}' must declare a certified transport.");
+ }
+ if (profile.CertificationStatus !=
+ UnitTrackingCertificationStatus.Certified &&
+ profile.CertifiedTransports.Count != 0)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' cannot certify transports before profile certification.");
+ }
+ if (profile.IsSelectable &&
+ profile.CertificationStatus !=
+ UnitTrackingCertificationStatus.Certified)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' cannot be selectable before certification.");
+ }
+
+ var nativeProfile =
+ profile.TransportType ==
+ UnitTrackingTransportType.NativeTcpUdp;
+ if (nativeProfile &&
+ profile.SupportedTransports.Any(transport =>
+ !string.Equals(
+ transport,
+ "Tcp",
+ StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(
+ transport,
+ "Udp",
+ StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new InvalidOperationException(
+ $"Native profile '{profile.Key}' declares a non-socket transport.");
+ }
+ if (!nativeProfile &&
+ string.IsNullOrWhiteSpace(
+ profile.PayloadAdapterKey))
+ {
+ throw new InvalidOperationException(
+ $"Non-native profile '{profile.Key}' must declare a payload adapter.");
+ }
+
+ if (string.IsNullOrWhiteSpace(profile.IoMapKey))
+ return;
+
+ RequireCanonicalKey(
+ profile.IoMapKey,
+ $"I/O map key for '{profile.Key}'");
+ var ioMap = ioMaps.SingleOrDefault(candidate =>
+ string.Equals(
+ candidate.Key,
+ profile.IoMapKey,
+ StringComparison.OrdinalIgnoreCase));
+ if (ioMap == null)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' references an unknown I/O map.");
+ }
+ if (!string.Equals(
+ ioMap.ProtocolKey,
+ profile.ProtocolKey,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profile.Key}' references an I/O map for another protocol.");
+ }
+ }
+
+ private static void ValidateIoMap(
+ UnitTrackingIoMap ioMap)
+ {
+ if (ioMap == null)
+ throw new InvalidOperationException(
+ "The unit tracking catalog contains a null I/O map.");
+
+ RequireText(ioMap.Key, "I/O map key");
+ RequireCanonicalKey(ioMap.Key, "I/O map key");
+ RequireText(
+ ioMap.ProtocolKey,
+ $"protocol key for I/O map '{ioMap.Key}'");
+ RequireCanonicalKey(
+ ioMap.ProtocolKey,
+ $"protocol key for I/O map '{ioMap.Key}'");
+ RequireText(
+ ioMap.ProtocolDocumentVersion,
+ $"protocol document version for I/O map '{ioMap.Key}'");
+ if (ioMap.Mappings == null ||
+ ioMap.Mappings.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' must contain mappings.");
+ }
+ if (ioMap.Mappings.GroupBy(mapping => mapping.AvlId)
+ .Any(group => group.Count() > 1))
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains a duplicate AVL ID.");
+ }
+ if (ioMap.Mappings.GroupBy(mapping => mapping.Target)
+ .Any(group => group.Count() > 1))
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains a duplicate canonical target.");
+ }
+
+ foreach (var mapping in ioMap.Mappings)
+ {
+ if (mapping.AvlId < 0 ||
+ mapping.AvlId > ushort.MaxValue)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains an invalid AVL ID.");
+ }
+ if (mapping.ValueBytes != 1 &&
+ mapping.ValueBytes != 2 &&
+ mapping.ValueBytes != 4 &&
+ mapping.ValueBytes != 8)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains an invalid value width.");
+ }
+ if (!Enum.IsDefined(mapping.Target) ||
+ mapping.Target ==
+ UnitTrackingIoTarget.Unknown)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains an invalid target.");
+ }
+ if (mapping.Multiplier <= 0)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains an invalid multiplier.");
+ }
+ var maximumValue =
+ mapping.ValueBytes == 8
+ ? ulong.MaxValue
+ : (1UL << (mapping.ValueBytes * 8)) - 1;
+ if (!mapping.MinimumRawValue.HasValue ||
+ !mapping.MaximumRawValue.HasValue ||
+ mapping.MinimumRawValue.Value >
+ mapping.MaximumRawValue.Value ||
+ mapping.MaximumRawValue.Value >
+ maximumValue)
+ {
+ throw new InvalidOperationException(
+ $"I/O map '{ioMap.Key}' contains an invalid raw value range.");
+ }
+ }
+ }
+
+ private static void ValidateUniqueKeys(
+ IEnumerable keys,
+ string itemType)
+ {
+ if (keys.Any(string.IsNullOrWhiteSpace))
+ {
+ throw new InvalidOperationException(
+ $"The unit tracking catalog contains a {itemType} without a key.");
+ }
+ if (keys.GroupBy(
+ key => key,
+ StringComparer.OrdinalIgnoreCase)
+ .Any(group => group.Count() > 1))
+ {
+ throw new InvalidOperationException(
+ $"The unit tracking catalog contains a duplicate {itemType} key.");
+ }
+ }
+
+ private static void ValidateTransports(
+ string profileKey,
+ IReadOnlyCollection transports)
+ {
+ if (transports.Any(transport =>
+ !string.Equals(
+ transport,
+ "Https",
+ StringComparison.Ordinal) &&
+ !string.Equals(
+ transport,
+ "Tcp",
+ StringComparison.Ordinal) &&
+ !string.Equals(
+ transport,
+ "Udp",
+ StringComparison.Ordinal)))
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profileKey}' declares an invalid transport.");
+ }
+ if (transports.Distinct(
+ StringComparer.OrdinalIgnoreCase)
+ .Count() != transports.Count)
+ {
+ throw new InvalidOperationException(
+ $"Profile '{profileKey}' declares a duplicate transport.");
+ }
+ }
+
+ private static void RequireText(
+ string value,
+ string fieldName)
+ {
+ if (string.IsNullOrWhiteSpace(value) ||
+ !string.Equals(
+ value,
+ value.Trim(),
+ StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"The unit tracking catalog has an invalid {fieldName}.");
+ }
+ }
+
+ private static void RequireCanonicalKey(
+ string value,
+ string fieldName)
+ {
+ if (value.StartsWith(
+ "-",
+ StringComparison.Ordinal) ||
+ value.EndsWith(
+ "-",
+ StringComparison.Ordinal) ||
+ value.Contains(
+ "--",
+ StringComparison.Ordinal) ||
+ value.Any(character =>
+ !(character >= 'a' && character <= 'z') &&
+ !(character >= '0' && character <= '9') &&
+ character != '-'))
+ {
+ throw new InvalidOperationException(
+ $"The unit tracking catalog has a non-canonical {fieldName}.");
+ }
+ }
+
+ private sealed class UnitTrackingCatalogDocument
+ {
+ public IReadOnlyCollection Profiles { get; set; }
+ public IReadOnlyCollection IoMaps { get; set; }
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs
index f53abe061..f34ae862e 100644
--- a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs
+++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs
@@ -12,7 +12,14 @@ public sealed class UnitTrackingCatalogProfile
public UnitTrackingTransportType TransportType { get; set; }
public string ProtocolKey { get; set; }
public string PayloadAdapterKey { get; set; }
+ public string DecoderVariant { get; set; }
+ public IReadOnlyCollection SupportedTransports { get; set; } =
+ Array.Empty();
+ public IReadOnlyCollection CertifiedTransports { get; set; } =
+ Array.Empty();
public UnitTrackingCertificationStatus CertificationStatus { get; set; }
+ public string ProtocolDocumentVersion { get; set; }
+ public string IoMapKey { get; set; }
public bool IdentifierRequired { get; set; }
public bool IsSelectable { get; set; }
public IReadOnlyCollection SupportedAuthModes { get; set; } =
diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs b/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs
new file mode 100644
index 000000000..4dcf4ef44
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/UnitTrackingIoMap.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Tracking
+{
+ public enum UnitTrackingIoTarget
+ {
+ Unknown = 0,
+ Hdop = 1,
+ BatteryPercent = 2,
+ ExternalPowerVolts = 3,
+ SignalPercent = 4,
+ Ignition = 5,
+ IsMoving = 6
+ }
+
+ public sealed class UnitTrackingIoMap
+ {
+ public string Key { get; set; }
+ public string ProtocolKey { get; set; }
+ public string ProtocolDocumentVersion { get; set; }
+ public IReadOnlyCollection Mappings { get; set; } =
+ Array.Empty();
+ }
+
+ public sealed class UnitTrackingIoMapping
+ {
+ public int AvlId { get; set; }
+ public int ValueBytes { get; set; }
+ public UnitTrackingIoTarget Target { get; set; }
+ public decimal Multiplier { get; set; } = 1m;
+ public ulong? MinimumRawValue { get; set; }
+ public ulong? MaximumRawValue { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs b/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs
new file mode 100644
index 000000000..7b21e9cc6
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/UnitTrackingSourceNetworkPolicy.cs
@@ -0,0 +1,88 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+
+namespace Resgrid.Model.Tracking
+{
+ public static class UnitTrackingSourceNetworkPolicy
+ {
+ public static bool IsAllowed(
+ IPAddress remoteAddress,
+ string allowedSourceCidrs)
+ {
+ if (string.IsNullOrWhiteSpace(allowedSourceCidrs))
+ return true;
+ if (remoteAddress == null)
+ return false;
+
+ var ranges = allowedSourceCidrs.Split(
+ ',',
+ StringSplitOptions.RemoveEmptyEntries |
+ StringSplitOptions.TrimEntries);
+ return ranges.Length > 0 &&
+ ranges.Any(range => Contains(range, remoteAddress));
+ }
+
+ private static bool Contains(
+ string range,
+ IPAddress candidate)
+ {
+ var parts = range.Split(
+ '/',
+ 2,
+ StringSplitOptions.TrimEntries);
+ if (!IPAddress.TryParse(parts[0], out var network))
+ return false;
+
+ var candidateAddress = NormalizeFamily(
+ candidate,
+ network.AddressFamily);
+ if (candidateAddress == null)
+ return false;
+
+ var networkBytes = network.GetAddressBytes();
+ var candidateBytes = candidateAddress.GetAddressBytes();
+ var maximumPrefix = networkBytes.Length * 8;
+ var prefix = maximumPrefix;
+ if (parts.Length == 2 &&
+ (!int.TryParse(parts[1], out prefix) ||
+ prefix < 0 ||
+ prefix > maximumPrefix))
+ return false;
+
+ var fullBytes = prefix / 8;
+ var remainingBits = prefix % 8;
+ for (var index = 0; index < fullBytes; index++)
+ {
+ if (networkBytes[index] != candidateBytes[index])
+ return false;
+ }
+
+ if (remainingBits == 0)
+ return true;
+
+ var mask = (byte)(0xff << (8 - remainingBits));
+ return (networkBytes[fullBytes] & mask) ==
+ (candidateBytes[fullBytes] & mask);
+ }
+
+ private static IPAddress NormalizeFamily(
+ IPAddress address,
+ AddressFamily targetFamily)
+ {
+ if (address.AddressFamily == targetFamily)
+ return address;
+
+ if (targetFamily == AddressFamily.InterNetwork &&
+ address.IsIPv4MappedToIPv6)
+ return address.MapToIPv4();
+
+ if (targetFamily == AddressFamily.InterNetworkV6 &&
+ address.AddressFamily == AddressFamily.InterNetwork)
+ return address.MapToIPv6();
+
+ return null;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs
index 3ae3818fa..61767159b 100644
--- a/Core/Resgrid.Services/CommunicationService.cs
+++ b/Core/Resgrid.Services/CommunicationService.cs
@@ -566,7 +566,7 @@ public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit disp
return true;
}
- public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null)
+ public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false)
{
if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId))
return false;
@@ -615,7 +615,10 @@ public async Task SendNotificationAsync(string userId, int departmentId, s
try
{
- await _pushService.PushNotification(spm, userId, profile);
+ if (sendToICApp)
+ await _pushService.PushICNotification(spm, userId, profile);
+ else
+ await _pushService.PushNotification(spm, userId, profile);
}
catch (Exception ex)
diff --git a/Core/Resgrid.Services/IncidentCommandNotificationService.cs b/Core/Resgrid.Services/IncidentCommandNotificationService.cs
index e375f14b4..33b91faef 100644
--- a/Core/Resgrid.Services/IncidentCommandNotificationService.cs
+++ b/Core/Resgrid.Services/IncidentCommandNotificationService.cs
@@ -114,6 +114,33 @@ await BroadcastToIncidentAsync(command.DepartmentId, command.CallId, "Command Tr
new List { fromUserId, toUserId }, cancellationToken);
}
+ public async Task SendMessageToCommandAsync(int departmentId, string senderUserId, string title, string body, IEnumerable recipientUserIds, CancellationToken cancellationToken = default(CancellationToken))
+ {
+ var senderName = await ResolveDisplayNameAsync(senderUserId, null) ?? "A department member";
+ var fullBody = $"From {senderName}: {body}";
+
+ var recipients = (recipientUserIds ?? Enumerable.Empty())
+ .Where(u => !string.IsNullOrWhiteSpace(u))
+ .Distinct(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var recipientUserId in recipients)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ await SendToUserAsync(recipientUserId, departmentId, title, fullBody);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+ }
+ }
+
/// Routes one message to the resource behind an assignment: own-department users and units only.
private async Task NotifyResourceAsync(ResourceAssignment assignment, string title, string body, CancellationToken cancellationToken)
{
@@ -195,7 +222,8 @@ private async Task SendToUserAsync(string userId, int departmentId, string title
var departmentNumber = await _departmentSettingsService.GetTextToCallNumberForDepartmentAsync(departmentId);
var profile = await _userProfileService.GetProfileByUserIdAsync(userId);
- await _communicationService.SendNotificationAsync(userId, departmentId, body, departmentNumber, department, title, profile);
+ // Command notifications target the IC app's Novu subscriber so they land in the IC inbox, not the Responder one.
+ await _communicationService.SendNotificationAsync(userId, departmentId, body, departmentNumber, department, title, profile, true);
}
private async Task SendToUnitAsync(int unitId, int departmentId, int callId, string title, string body)
diff --git a/Core/Resgrid.Services/PushService.cs b/Core/Resgrid.Services/PushService.cs
index 021fff284..9c59a2bad 100644
--- a/Core/Resgrid.Services/PushService.cs
+++ b/Core/Resgrid.Services/PushService.cs
@@ -36,14 +36,23 @@ public async Task Register(PushUri pushUri)
return false;
var code = pushUri.PushLocation;
+ // IC app registrations target the IC-specific Novu subscriber, keeping its inbox/push separate from the Responder app.
+ var isICApp = string.Equals(pushUri.Source, "IC", StringComparison.OrdinalIgnoreCase);
+
+ if (isICApp)
+ await EnsureICUserSubscriber(pushUri, code);
// 1) iOS -> APNS
if (pushUri.PlatformType == (int)Platforms.iOS)
- return await _novuProvider.UpdateUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId);
+ return isICApp
+ ? await _novuProvider.UpdateICUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId)
+ : await _novuProvider.UpdateUserSubscriberApns(pushUri.UserId, code, pushUri.DeviceId);
// 2) Android -> FCM
if (pushUri.PlatformType == (int)Platforms.Android)
- return await _novuProvider.UpdateUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId);
+ return isICApp
+ ? await _novuProvider.UpdateICUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId)
+ : await _novuProvider.UpdateUserSubscriberFcm(pushUri.UserId, code, pushUri.DeviceId);
// 3) TODO: Web Push (other platforms)
return false;
@@ -56,6 +65,20 @@ public async Task UnRegister(PushUri pushUri)
return true;
}
+ private async Task EnsureICUserSubscriber(PushUri pushUri, string code)
+ {
+ try
+ {
+ var profile = await _userProfileService.GetProfileByUserIdAsync(pushUri.UserId);
+ await _novuProvider.CreateICUserSubscriber(pushUri.UserId, code, pushUri.DepartmentId,
+ profile?.MembershipEmail, profile?.FirstName, profile?.LastName);
+ }
+ catch (Exception ex)
+ {
+ Resgrid.Framework.Logging.LogException(ex);
+ }
+ }
+
public async Task RegisterUnit(PushUri pushUri)
{
if (pushUri == null || !pushUri.UnitId.HasValue || string.IsNullOrWhiteSpace(pushUri.DeviceId) || string.IsNullOrWhiteSpace(pushUri.PushLocation))
@@ -156,6 +179,35 @@ public async Task PushNotification(StandardPushMessage message, string use
return true;
}
+ ///
+ /// Pushes a notification to the user's IC app (distinct Novu subscriber) only — no legacy Azure path,
+ /// so devices running just the Responder app aren't alerted for IC-only events.
+ ///
+ public async Task PushICNotification(StandardPushMessage message, string userId, UserProfile profile = null)
+ {
+ if (message == null)
+ return false;
+
+ if (profile == null)
+ profile = await _userProfileService.GetProfileByUserIdAsync(userId);
+
+ if (profile != null && profile.SendNotificationPush)
+ {
+ string soundType = await GetSoundTypeAsync(message.DepartmentId, profile, PushSoundTypes.Notifiation, PushSoundTypes.ModernNotification);
+
+ try
+ {
+ if (!string.IsNullOrWhiteSpace(message.DepartmentCode))
+ await _novuProvider.SendICUserNotification(message.Title, message.SubTitle, userId, message.DepartmentCode, string.Format("N{0}", message.MessageId), soundType);
+ }
+ catch (Exception ex)
+ {
+ Framework.Logging.LogException(ex);
+ }
+ }
+ return true;
+ }
+
public async Task PushChat(StandardPushMessage message, string userId, UserProfile profile = null)
{
if (message == null)
diff --git a/Core/Resgrid.Services/UnitTrackingCatalogService.cs b/Core/Resgrid.Services/UnitTrackingCatalogService.cs
index 4e3441948..a052311b9 100644
--- a/Core/Resgrid.Services/UnitTrackingCatalogService.cs
+++ b/Core/Resgrid.Services/UnitTrackingCatalogService.cs
@@ -1,9 +1,6 @@
-using System;
using System.Collections.Generic;
-using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Resgrid.Model;
using Resgrid.Model.Services;
using Resgrid.Model.Tracking;
@@ -11,64 +8,12 @@ namespace Resgrid.Services
{
public class UnitTrackingCatalogService : IUnitTrackingCatalogService
{
- private static readonly IReadOnlyCollection Profiles =
- new[]
- {
- new UnitTrackingCatalogProfile
- {
- Key = "generic-https",
- ManufacturerKey = "generic",
- ManufacturerName = "Generic",
- Model = "Resgrid JSON",
- TransportType = UnitTrackingTransportType.NativeHttps,
- ProtocolKey = "resgrid-json",
- PayloadAdapterKey = "resgrid-json-v1",
- CertificationStatus = UnitTrackingCertificationStatus.Certified,
- IdentifierRequired = false,
- IsSelectable = true,
- SupportedAuthModes = new[]
- {
- UnitTrackingAuthMode.Bearer,
- UnitTrackingAuthMode.Basic,
- UnitTrackingAuthMode.CustomHeader,
- UnitTrackingAuthMode.CapabilityPath
- },
- SetupSummary =
- "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.",
- RetryExpectation =
- "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery."
- },
- new UnitTrackingCatalogProfile
- {
- Key = "traccar-forwarder",
- ManufacturerKey = "traccar",
- ManufacturerName = "Traccar",
- Model = "Position Forwarding",
- TransportType = UnitTrackingTransportType.ProtocolGateway,
- ProtocolKey = "traccar",
- PayloadAdapterKey = "traccar-json-v1",
- CertificationStatus = UnitTrackingCertificationStatus.Candidate,
- IdentifierRequired = true,
- IsSelectable = false,
- SupportedAuthModes = new[]
- {
- UnitTrackingAuthMode.Bearer,
- UnitTrackingAuthMode.Basic,
- UnitTrackingAuthMode.CustomHeader,
- UnitTrackingAuthMode.CapabilityPath
- },
- SetupSummary =
- "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.",
- RetryExpectation =
- "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures."
- }
- };
-
public Task> GetProfilesAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- return Task.FromResult(Profiles);
+ return Task.FromResult(
+ UnitTrackingCatalog.Profiles);
}
public Task GetProfileAsync(
@@ -76,9 +21,8 @@ public Task GetProfileAsync(
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
- var profile = Profiles.FirstOrDefault(item =>
- string.Equals(item.Key, profileKey?.Trim(), StringComparison.OrdinalIgnoreCase));
- return Task.FromResult(profile);
+ return Task.FromResult(
+ UnitTrackingCatalog.GetProfile(profileKey));
}
}
}
diff --git a/Core/Resgrid.Services/UnitTrackingIngressService.cs b/Core/Resgrid.Services/UnitTrackingIngressService.cs
index 01d7e8b5f..27cb5de90 100644
--- a/Core/Resgrid.Services/UnitTrackingIngressService.cs
+++ b/Core/Resgrid.Services/UnitTrackingIngressService.cs
@@ -134,6 +134,67 @@ await TryUpdateDeviceStatusAsync(
};
}
+ public async Task AcceptHeartbeatAsync(
+ AuthenticatedTrackingSource source,
+ DateTime receivedOnUtc,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var receivedOn = EnsureUtc(
+ receivedOnUtc == default
+ ? DateTime.UtcNow
+ : receivedOnUtc);
+
+ if (!IsEnabled(source?.Device))
+ return Invalid(
+ receivedOn,
+ "The tracking binding is not enabled.");
+
+ var device = source.Device;
+ var unit = await _unitsService.GetUnitByIdAsync(
+ device.UnitId);
+ if (unit == null ||
+ unit.DepartmentId != device.DepartmentId)
+ {
+ await TryUpdateDeviceStatusAsync(
+ device,
+ receivedOn,
+ null,
+ "tenant-binding-invalid",
+ cancellationToken);
+ return Invalid(
+ receivedOn,
+ "The tracking binding is invalid.");
+ }
+
+ if (!IdentifierMatches(
+ device.DeviceIdentifier,
+ source.ReportedDeviceIdentifier))
+ {
+ await TryUpdateDeviceStatusAsync(
+ device,
+ receivedOn,
+ null,
+ "identifier-mismatch",
+ cancellationToken);
+ return Invalid(
+ receivedOn,
+ "The reported device identifier does not match the binding.");
+ }
+
+ await TryUpdateDeviceStatusAsync(
+ device,
+ receivedOn,
+ null,
+ null,
+ cancellationToken);
+ return new TrackingIngressResult
+ {
+ Status = TrackingIngressStatus.Accepted,
+ ReceivedOn = receivedOn
+ };
+ }
+
private NormalizationResult NormalizeAll(
IReadOnlyCollection positions,
int retentionDays)
diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml
index 8b38f2462..6d426c085 100644
--- a/Docker/docker-compose.yml
+++ b/Docker/docker-compose.yml
@@ -9,8 +9,8 @@ services:
- resgrid.env
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.51
+ rgmain:
+ ipv4_address: 172.16.193.51
depends_on:
- api
- events
@@ -31,8 +31,8 @@ services:
- resgrid.env
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.52
+ rgmain:
+ ipv4_address: 172.16.193.52
depends_on:
- events
- db
@@ -52,8 +52,8 @@ services:
- resgrid.env
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.53
+ rgmain:
+ ipv4_address: 172.16.193.53
depends_on:
- db
- redis
@@ -69,8 +69,8 @@ services:
- resgrid.env
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.54
+ rgmain:
+ ipv4_address: 172.16.193.54
depends_on:
- db
- redis
@@ -81,12 +81,44 @@ services:
- WAIT_AFTER=90
- WAIT_TIMEOUT=180
+ tracker-gateway:
+ profiles: ["tracking"]
+ image: "resgridllc/resgridtrackergateway:0.6.70"
+ env_file:
+ - resgrid.env
+ restart: always
+ ports:
+ - "5004:5004/tcp"
+ - "5004:5004/udp"
+ - "5023:5023/tcp"
+ - "5023:5023/udp"
+ - "5027:5027/tcp"
+ - "5027:5027/udp"
+ networks:
+ rgmain:
+ ipv4_address: 172.16.193.60
+ depends_on:
+ - db
+ - redis
+ - rabbitmq
+ environment:
+ - WAIT_HOSTS=db:1433,redis:6379,rabbitmq:15672
+ - WAIT_TIMEOUT=300
+ # LocalXpose tunneling for the raw TCP/UDP ingest ports (https://localxpose.io); disabled by default.
+ - "LOCALXPOSE_ENABLED=${LOCALXPOSE_ENABLED:-false}"
+ - "LOCALXPOSE_ACCESS_TOKEN=${LOCALXPOSE_ACCESS_TOKEN:-}"
+ - "LOCALXPOSE_REGION=${LOCALXPOSE_REGION:-us}"
+ - "LOCALXPOSE_TARGET_HOST=${LOCALXPOSE_TARGET_HOST:-localhost}"
+ - "LOCALXPOSE_TCP_PORTS=${LOCALXPOSE_TCP_PORTS:-5004 5023 5027}"
+ - "LOCALXPOSE_UDP_PORTS=${LOCALXPOSE_UDP_PORTS:-}"
+ - "LOCALXPOSE_RESERVED_ENDPOINTS=${LOCALXPOSE_RESERVED_ENDPOINTS:-}"
+
db:
ports:
- "5157:1433"
networks:
- - rgmain:
- ipv4_address: 172.16.193.55
+ rgmain:
+ ipv4_address: 172.16.193.55
build: ./db
environment:
- SA_PASSWORD=Resgrid123!!
@@ -110,8 +142,8 @@ services:
- "5158:6379"
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.56
+ rgmain:
+ ipv4_address: 172.16.193.56
rabbitmq:
image: rabbitmq:3-management
@@ -123,8 +155,8 @@ services:
- "5159:5672"
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.57
+ rgmain:
+ ipv4_address: 172.16.193.57
elk:
image: sebp/elk
@@ -134,8 +166,8 @@ services:
- "5165:5044"
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.58
+ rgmain:
+ ipv4_address: 172.16.193.58
mongodb:
image: mongo:4.4.18
@@ -143,8 +175,8 @@ services:
- 27017:27017
restart: always
networks:
- - rgmain:
- ipv4_address: 172.16.193.59
+ rgmain:
+ ipv4_address: 172.16.193.59
environment:
- MONGO_INITDB_DATABASE=resgrid
- MONGO_INITDB_ROOT_USERNAME=resgridUser
@@ -178,4 +210,4 @@ networks:
ipam:
driver: default
config:
- - subnet: 172.16.193.0/24
\ No newline at end of file
+ - subnet: 172.16.193.0/24
diff --git a/Docker/monitoring/grafana/resgrid-tracker-gateway.json b/Docker/monitoring/grafana/resgrid-tracker-gateway.json
new file mode 100644
index 000000000..8f73e390b
--- /dev/null
+++ b/Docker/monitoring/grafana/resgrid-tracker-gateway.json
@@ -0,0 +1,508 @@
+{
+ "__inputs": [
+ {
+ "name": "DS_PROMETHEUS",
+ "label": "Prometheus",
+ "description": "Prometheus data source containing tracker gateway and RabbitMQ metrics.",
+ "type": "datasource",
+ "pluginId": "prometheus",
+ "pluginName": "Prometheus"
+ }
+ ],
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "graphTooltip": 1,
+ "id": null,
+ "links": [],
+ "panels": [
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "red",
+ "value": null
+ },
+ {
+ "color": "green",
+ "value": 1
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 4,
+ "x": 0,
+ "y": 0
+ },
+ "id": 1,
+ "options": {
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "expr": "min(resgrid_tracker_gateway_ready)",
+ "refId": "A"
+ }
+ ],
+ "title": "Gateway ready",
+ "type": "stat"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 1
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 4,
+ "x": 4,
+ "y": 0
+ },
+ "id": 2,
+ "options": {
+ "colorMode": "background",
+ "graphMode": "none",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "expr": "sum(resgrid_tracker_gateway_listeners_expected - resgrid_tracker_gateway_listeners_bound)",
+ "refId": "A"
+ }
+ ],
+ "title": "Missing listeners",
+ "type": "stat"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "decimals": 1,
+ "max": 100,
+ "min": 0,
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "yellow",
+ "value": 80
+ },
+ {
+ "color": "red",
+ "value": 95
+ }
+ ]
+ },
+ "unit": "percent"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 8,
+ "y": 0
+ },
+ "id": 3,
+ "options": {
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showThresholdLabels": false,
+ "showThresholdMarkers": true
+ },
+ "targets": [
+ {
+ "expr": "100 * sum(resgrid_tracking_connections_current) / clamp_min(sum(resgrid_tracker_gateway_connections_limit), 1)",
+ "refId": "A"
+ }
+ ],
+ "title": "Connection capacity",
+ "type": "gauge"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "mappings": [],
+ "thresholds": {
+ "mode": "absolute",
+ "steps": [
+ {
+ "color": "green",
+ "value": null
+ },
+ {
+ "color": "red",
+ "value": 1
+ }
+ ]
+ }
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 5,
+ "w": 8,
+ "x": 16,
+ "y": 0
+ },
+ "id": 4,
+ "options": {
+ "colorMode": "value",
+ "graphMode": "area",
+ "justifyMode": "auto",
+ "orientation": "auto",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "textMode": "auto"
+ },
+ "targets": [
+ {
+ "expr": "sum(rabbitmq_detailed_queue_messages_ready{queue=~\"^(DEV|QA|ST)?unitlocation-v2(test)?\\\\.dead$\"})",
+ "refId": "A"
+ }
+ ],
+ "title": "Unit-location dead letters",
+ "type": "stat"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ "unit": "msgps"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 0,
+ "y": 5
+ },
+ "id": 5,
+ "options": {
+ "legend": {
+ "calcs": [
+ "lastNotNull",
+ "mean"
+ ],
+ "displayMode": "table",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "expr": "sum by (transport, protocol, outcome) (rate(resgrid_tracking_ingress_messages_total[$__rate_interval]))",
+ "legendFormat": "{{transport}} / {{protocol}} / {{outcome}}",
+ "refId": "A"
+ }
+ ],
+ "title": "Native ingress rate",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ }
+ },
+ "unit": "s"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 12,
+ "x": 12,
+ "y": 5
+ },
+ "id": 6,
+ "options": {
+ "legend": {
+ "calcs": [
+ "lastNotNull",
+ "mean"
+ ],
+ "displayMode": "table",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "expr": "histogram_quantile(0.95, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[$__rate_interval])))",
+ "legendFormat": "p95 {{transport}}",
+ "refId": "A"
+ },
+ {
+ "expr": "histogram_quantile(0.50, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[$__rate_interval])))",
+ "legendFormat": "p50 {{transport}}",
+ "refId": "B"
+ }
+ ],
+ "title": "Canonical ingress duration",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 0,
+ "y": 13
+ },
+ "id": 7,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "expr": "sum by (protocol, reason) (rate(resgrid_tracking_parse_failures_total[$__rate_interval]))",
+ "legendFormat": "{{protocol}} / {{reason}}",
+ "refId": "A"
+ }
+ ],
+ "title": "Parser failures",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "normal"
+ }
+ },
+ "unit": "ops"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 8,
+ "y": 13
+ },
+ "id": 8,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "expr": "sum by (transport, reason) (rate(resgrid_tracking_auth_failures_total[$__rate_interval]))",
+ "legendFormat": "{{transport}} / {{reason}}",
+ "refId": "A"
+ }
+ ],
+ "title": "Mapping and source-policy failures",
+ "type": "timeseries"
+ },
+ {
+ "datasource": "${DS_PROMETHEUS}",
+ "fieldConfig": {
+ "defaults": {
+ "custom": {
+ "drawStyle": "line",
+ "fillOpacity": 10,
+ "lineInterpolation": "linear",
+ "lineWidth": 1,
+ "pointSize": 5,
+ "showPoints": "never",
+ "spanNulls": false,
+ "stacking": {
+ "group": "A",
+ "mode": "none"
+ }
+ },
+ "unit": "short"
+ },
+ "overrides": []
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 8,
+ "x": 16,
+ "y": 13
+ },
+ "id": 9,
+ "options": {
+ "legend": {
+ "displayMode": "list",
+ "placement": "bottom",
+ "showLegend": true
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "expr": "sum by (queue) (rabbitmq_detailed_queue_messages_ready{queue=~\"^(DEV|QA|ST)?unitlocation-v2(test)?\\\\.(retry|dead)$\"})",
+ "legendFormat": "{{queue}}",
+ "refId": "A"
+ }
+ ],
+ "title": "Retry and dead-letter queue depth",
+ "type": "timeseries"
+ }
+ ],
+ "refresh": "30s",
+ "schemaVersion": 39,
+ "tags": [
+ "resgrid",
+ "tracking",
+ "gateway"
+ ],
+ "templating": {
+ "list": []
+ },
+ "time": {
+ "from": "now-6h",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "browser",
+ "title": "Resgrid Tracker Gateway",
+ "uid": "resgrid-tracker-gateway",
+ "version": 1,
+ "weekStart": ""
+}
diff --git a/Docker/monitoring/tracker-gateway-alerts.yml b/Docker/monitoring/tracker-gateway-alerts.yml
new file mode 100644
index 000000000..1141f6e2d
--- /dev/null
+++ b/Docker/monitoring/tracker-gateway-alerts.yml
@@ -0,0 +1,113 @@
+groups:
+ - name: resgrid-tracker-gateway
+ rules:
+ - alert: ResgridTrackerGatewayNotReady
+ expr: resgrid_tracker_gateway_ready == 0
+ for: 2m
+ labels:
+ severity: critical
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracker gateway is not ready
+ description: One or more configured native listeners are not ready.
+
+ - alert: ResgridTrackerGatewayListenerMismatch
+ expr: resgrid_tracker_gateway_listeners_expected - resgrid_tracker_gateway_listeners_bound > 0
+ for: 2m
+ labels:
+ severity: critical
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: A required Resgrid tracking listener is not bound
+ description: The number of bound native listeners is below the configured listener count.
+
+ - alert: ResgridTrackerGatewayConnectionSaturation
+ expr: sum(resgrid_tracking_connections_current) / clamp_min(sum(resgrid_tracker_gateway_connections_limit), 1) > 0.80
+ for: 10m
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracker gateway is approaching its connection limit
+ description: More than 80 percent of configured global session capacity has been used for ten minutes.
+
+ - alert: ResgridTrackerGatewayAdmissionRejections
+ expr: sum(increase(resgrid_tracking_connections_total{outcome="admission-rejected"}[5m])) > 10
+ for: 5m
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracker gateway is rejecting connections
+ description: Global or per-IP admission limits rejected more than ten sessions in five minutes.
+
+ - alert: ResgridTrackerGatewayPublishFailures
+ expr: sum(rate(resgrid_tracking_ingress_messages_total{outcome="unavailable"}[5m])) / clamp_min(sum(rate(resgrid_tracking_ingress_messages_total[5m])), 0.001) > 0.05
+ for: 5m
+ labels:
+ severity: critical
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracking ingress publish failures are elevated
+ description: More than five percent of native messages have been unavailable for five minutes.
+
+ - alert: ResgridTrackerGatewayPublishLatency
+ expr: histogram_quantile(0.95, sum by (le, transport) (rate(resgrid_tracking_queue_publish_duration_seconds_bucket[10m]))) > 0.5
+ for: 10m
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracking queue publish latency is high
+ description: The p95 canonical ingress duration has exceeded 500 milliseconds for ten minutes.
+
+ - alert: ResgridTrackerGatewayParserFailureSpike
+ expr: sum by (protocol) (rate(resgrid_tracking_parse_failures_total[5m])) > 1
+ for: 5m
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracking parser failures are elevated
+ description: A bounded protocol parser is rejecting more than one frame per second.
+
+ - alert: ResgridTrackerGatewayAuthenticationFailureSpike
+ expr: sum by (transport) (rate(resgrid_tracking_auth_failures_total[5m])) > 1
+ for: 5m
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracking authentication failures are elevated
+ description: Unknown-device, mapping, or source-policy failures exceed one per second.
+
+ - alert: ResgridTrackerGatewayForcedShutdown
+ expr: sum(increase(resgrid_tracking_shutdown_forced_total[15m])) > 0
+ labels:
+ severity: warning
+ service: resgrid-tracker-gateway
+ annotations:
+ summary: Resgrid tracker gateway exhausted its graceful drain deadline
+ description: At least one listener required a forced shutdown during the last fifteen minutes.
+
+ - name: resgrid-unit-location-queues
+ rules:
+ - alert: ResgridUnitLocationDeadLetters
+ expr: sum(rabbitmq_detailed_queue_messages_ready{queue=~"^(DEV|QA|ST)?unitlocation-v2(test)?\\.dead$"}) > 0
+ for: 2m
+ labels:
+ severity: critical
+ service: resgrid-unit-location-worker
+ annotations:
+ summary: Resgrid v2 unit-location dead letters are present
+ description: Inspect and resolve dead-lettered hardware-location events before replay.
+
+ - alert: ResgridUnitLocationRetryBacklog
+ expr: sum(rabbitmq_detailed_queue_messages_ready{queue=~"^(DEV|QA|ST)?unitlocation-v2(test)?\\.retry$"}) > 1000
+ for: 10m
+ labels:
+ severity: warning
+ service: resgrid-unit-location-worker
+ annotations:
+ summary: Resgrid v2 unit-location retry backlog is growing
+ description: More than one thousand hardware-location events have remained in retry queues for ten minutes.
diff --git a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
index 913c64144..6eeae70e7 100644
--- a/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
+++ b/Providers/Resgrid.Providers.Messaging/NovuProvider.cs
@@ -81,6 +81,13 @@ public async Task CreateUserSubscriber(string userId, string code, int dep
return await CreateSubscriber($"{code}_User_{userId}", departmentId, email, firstName, lastName, null);
}
+ public async Task CreateICUserSubscriber(string userId, string code, int departmentId, string email,
+ string firstName, string lastName)
+ {
+ // The IC app gets its own subscriber id so its Novu inbox is separate from the Responder app's.
+ return await CreateSubscriber($"{code}_IC_User_{userId}", departmentId, email, firstName, lastName, null);
+ }
+
public async Task CreateUnitSubscriber(int unitId, string code, int departmentId, string unitName, string deviceId)
{
var data = new List();
@@ -225,6 +232,16 @@ public async Task UpdateUserSubscriberApns(string userId, string code, str
return await UpdateSubscriberApns($"{code}_User_{userId}", token, ChatConfig.NovuResponderApnsProviderId, null);
}
+ public async Task UpdateICUserSubscriberFcm(string userId, string code, string token)
+ {
+ return await UpdateSubscriberFcm($"{code}_IC_User_{userId}", token, ChatConfig.NovuICFcmProviderId);
+ }
+
+ public async Task UpdateICUserSubscriberApns(string userId, string code, string token)
+ {
+ return await UpdateSubscriberApns($"{code}_IC_User_{userId}", token, ChatConfig.NovuICApnsProviderId, null);
+ }
+
public async Task UpdateUnitSubscriberFcm(int unitId, string code, string token)
{
return await UpdateSubscriberFcm($"{code}_Unit_{unitId}", token, ChatConfig.NovuUnitFcmProviderId);
@@ -364,6 +381,11 @@ public async Task SendUserNotification(string title, string body, string u
return await SendNotification(title, body, $"{depCode}_User_{userId}", eventCode, type, false, 0, null, ChatConfig.NovuNotificationUserWorkflowId, GetSoundFileNameFromType(type));
}
+ public async Task SendICUserNotification(string title, string body, string userId, string depCode, string eventCode, string type)
+ {
+ return await SendNotification(title, body, $"{depCode}_IC_User_{userId}", eventCode, type, false, 0, null, ChatConfig.NovuNotificationUserWorkflowId, GetSoundFileNameFromType(type));
+ }
+
#region Private Push Helpers
private string GetSoundFileNameFromType(string type)
diff --git a/Providers/Resgrid.Providers.Tracking/NOTICE.md b/Providers/Resgrid.Providers.Tracking/NOTICE.md
index c494ba6ed..0fd495576 100644
--- a/Providers/Resgrid.Providers.Tracking/NOTICE.md
+++ b/Providers/Resgrid.Providers.Tracking/NOTICE.md
@@ -3,7 +3,10 @@
## Traccar
The Resgrid `traccar-json-v1` interoperability adapter was independently implemented
-against Traccar's public JSON position-forwarding contract.
+against Traccar's public JSON position-forwarding contract. The bounded
+`queclink-attrack` and `gt06` protocol modules are C# adaptations informed by the
+pinned Traccar decoders, frame handlers, and tests. Their interoperability fixtures
+include sanitized packets copied from the pinned Traccar test suite.
- Project: Traccar GPS Tracking System
- Repository: https://github.com/traccar/traccar
@@ -12,5 +15,6 @@ against Traccar's public JSON position-forwarding contract.
- License: Apache License 2.0
- Copyright: Anton Tananaev and Traccar contributors
-No Traccar Java source is compiled into or redistributed with Resgrid. See
-`PROVENANCE.md` for the exact files reviewed and the adaptation record.
+No Traccar Java source is compiled into Resgrid. The repository root `LICENSE` contains
+the Apache License 2.0. See `PROVENANCE.md` for the exact files reviewed, adapted
+behavior, copied fixtures, and certification limits.
diff --git a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md
index 50e382171..fc40429c6 100644
--- a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md
+++ b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md
@@ -43,3 +43,181 @@ contract. No Java implementation code was copied or ported. The mapping intentio
independently generated, sanitized fixture representing a long-tail SinoTrack ST-901
decoded by Traccar's `h02` protocol. It contains no upstream or customer packet data and
does not constitute physical-hardware certification.
+
+## `teltonika-codec8`
+
+### Public material reviewed
+
+- Official protocol documentation:
+ https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols
+- Official Wave 1 model parameter tables:
+ - https://wiki.teltonika-gps.com/view/FMC920_Teltonika_Data_Sending_Parameters_ID
+ - https://wiki.teltonika-gps.com/view/FMM920_Teltonika_Data_Sending_Parameters_ID
+ - https://wiki.teltonika-gps.com/view/FMC130_Teltonika_Data_Sending_Parameters_ID
+ - https://wiki.teltonika-gps.com/view/FMM130_Teltonika_Data_Sending_Parameters_ID
+ - https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID
+- Reviewed: 2026-07-26
+
+### Adaptation record
+
+The Resgrid module is an independent C# implementation of the public Teltonika data
+sending protocol. No vendor implementation code was copied or ported. The initial
+bounded scope:
+
+- accepts the 15-digit TCP IMEI login and emits the documented binary accept/reject
+ response;
+- accepts the UDP channel wrapper with its declared length, channel packet ID, marker,
+ AVL packet ID, and embedded 15-digit IMEI;
+- parses Codec8 (`0x08`) and Codec8 Extended (`0x8E`) AVL record arrays;
+- validates the preamble, big-endian data length, CRC-16/IBM, codec, record counts,
+ coordinates, timestamps, priority, angle, and bounded I/O-element structure;
+- maps codec-defined GPS fields to canonical tracking positions;
+- loads the five Wave 1 profiles and their I/O map from the validated embedded catalog;
+- registers seven WP11 family candidates against the same module without assigning
+ those models an I/O map;
+- retains only fixed-width numeric I/O values allowlisted by that catalog, then applies
+ the selected model profile only after IMEI authentication;
+- maps AVL 182 to HDOP with multiplier `0.1`, AVL 66 to external power volts with
+ multiplier `0.001`, AVL 239 to ignition, and AVL 240 to movement, enforcing the
+ documented raw ranges;
+- ignores all other I/O values and discards the bounded parser metadata before canonical
+ ingress;
+- derives deterministic SHA-256 event fingerprints from each raw AVL record;
+- emits the TCP four-byte accepted-record count or UDP channel response with matching
+ packet IDs only when canonical ingress accepts the complete packet.
+
+The FMC920, FMM920, FMC130, FMM130, and FMC003 Wave 1 profiles and the FMM003, FMC125,
+FMM125, FMC150, FMM150, FMC230, and FMM230 WP11 profiles remain non-selectable
+`Candidate` entries with no certified transports. The WP11 entries are catalog-only
+family-placement hypotheses and deliberately have no model I/O map. Captured
+model/firmware fixtures, exact firmware pins, model mapping review, and physical-device
+certification are not part of this documentation-derived pass. The test-only TCP and
+UDP simulators are generated from the public packet layout and are not certification
+evidence.
+
+### Fixtures
+
+`Tests/Resgrid.Tracking.Tests/Data/Teltonika/` contains independently generated minimal
+Codec8 and Codec8 Extended TCP packets, their UDP channel wrappers, and a synthetic
+nonzero-I/O Codec8 packet covering the four allowlisted values. They are based on the
+public field layout, contain no vendor or customer packet data, and do not constitute
+physical-hardware certification. The test-only TCP simulator uses these fixtures to
+exercise login, fragmented current/buffered batches, duplicate resend, CRC rejection,
+mid-frame disconnect, and ACK timing relative to canonical ingress confirmation. The
+UDP simulator covers wrapper construction, matching response IDs and record counts,
+buffered/duplicate datagrams, malformed count rejection, and the same ACK ordering.
+
+## `queclink-attrack`
+
+### Pin
+
+- Upstream repository: https://github.com/traccar/traccar
+- Release: `v6.14.5`
+- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9`
+- Commit date: 2026-06-18
+- License: Apache License 2.0
+
+### Upstream source reviewed
+
+- `src/main/java/org/traccar/protocol/Gl200Protocol.java`
+- `src/main/java/org/traccar/protocol/Gl200FrameDecoder.java`
+- `src/main/java/org/traccar/protocol/Gl200ProtocolDecoder.java`
+- `src/main/java/org/traccar/protocol/Gl200TextProtocolDecoder.java`
+- `src/main/java/org/traccar/protocol/Gl200BinaryProtocolDecoder.java`
+- `src/test/java/org/traccar/protocol/Gl200TextProtocolDecoderTest.java`
+- `src/test/java/org/traccar/protocol/Gl200BinaryProtocolDecoderTest.java`
+
+All source-file references are pinned to commit
+`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`.
+
+### Adaptation record
+
+The Resgrid module is a bounded C# adaptation informed by the pinned Traccar GL200
+implementation. No Traccar Java source is compiled into Resgrid. The initial scope:
+
+- accepts printable ASCII `+RESP` and `+BUFF` reports terminated by `$` or NUL, with
+ strict frame, field-count, field-length, protocol-version, and 15-digit IMEI bounds;
+- accepts only an explicit allowlist of position, status, ignition, and alarm report
+ types and rejects unknown report types instead of guessing a layout;
+- recognizes the bounded GL200 location tuple and maps timestamp, coordinates, speed,
+ heading, altitude, HDOP, movement, ignition, battery, external power, and selected
+ alarms into the canonical tracking model;
+- accepts `+ACK:GTHBD` heartbeat reports and emits the matching `+SACK:GTHBD` only after
+ canonical ingress accepts the heartbeat;
+- leaves position acknowledgements disabled because their behavior is
+ configuration-dependent in the pinned implementation;
+- derives deterministic SHA-256 event fingerprints from the raw report and position
+ index;
+- supports TCP only. UDP and GL200 binary framing are deliberately not registered.
+
+The GV57MG, GV350MG, and GV500MA profiles remain non-selectable `Candidate` entries
+with no certified transports. Current manufacturer documents, captured
+model/firmware packets, and physical-device certification remain required before any
+profile or transport is promoted.
+
+### Fixtures
+
+`Tests/Resgrid.Tracking.Tests/Data/Queclink/` contains sanitized ASCII messages copied
+from the pinned `Gl200TextProtocolDecoderTest`. They exercise live and buffered
+positions, ignition, and heartbeat/response behavior. They are upstream
+interoperability fixtures, not captured packets from the three target devices and not
+certification evidence. The test-only TCP simulator uses them to exercise fragmented
+and duplicate reports plus heartbeat response behavior through the real listener.
+
+## `gt06`
+
+### Pin
+
+- Upstream repository: https://github.com/traccar/traccar
+- Release: `v6.14.5`
+- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9`
+- Commit date: 2026-06-18
+- License: Apache License 2.0
+
+### Upstream source reviewed
+
+- `src/main/java/org/traccar/protocol/Gt06Protocol.java`
+- `src/main/java/org/traccar/protocol/Gt06FrameDecoder.java`
+- `src/main/java/org/traccar/protocol/Gt06ProtocolDecoder.java`
+- `src/test/java/org/traccar/protocol/Gt06ProtocolDecoderTest.java`
+
+All source-file references are pinned to commit
+`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`.
+
+### Adaptation record
+
+The Resgrid module is a bounded C# adaptation informed by the pinned Traccar GT06
+implementation. No Traccar Java source is compiled into Resgrid. The initial scope:
+
+- accepts short `0x7878` and extended `0x7979` frames with bounded declared lengths,
+ `0x0D0A` terminators, serial numbers, and CRC-16/X25 validation;
+- requires a valid BCD-encoded 15-digit IMEI login before all other messages;
+- accepts the bounded login, heartbeat/status, GPS/LBS `0x22`, GPS/LBS status/alarm
+ `0x16`, and JM-VL03 `0xA0` location layouts selected for this implementation;
+- maps timestamp, coordinates, fix validity, satellites, speed, heading, movement,
+ ignition, battery, signal, and selected model-aware alarms into the canonical model;
+- emits CRC-protected acknowledgements with the original header form, protocol number,
+ and serial only after login or complete canonical-ingress acceptance;
+- derives deterministic SHA-256 event fingerprints from the complete raw frame;
+- rejects unrecognized message types rather than attempting universal GT06-clone
+ detection;
+- supports TCP only. UDP is deliberately not registered.
+
+The VL103M and JM-VL03 Wave 1 profiles and JM-VL01, JM-VL02, and JM-VL04 WP11 profiles
+remain non-selectable `Candidate` entries with no certified transports. The WP11
+decoder-variant names are explicitly marked unverified; they do not add accepted
+message layouts or claim that a sibling uses one of the tested Wave 1 layouts. Current
+manufacturer documents, captured model/firmware packets, exact variant dispatch, and
+physical-device certification remain required before any profile or transport is
+promoted.
+
+### Fixtures
+
+`Tests/Resgrid.Tracking.Tests/Data/Gt06/` contains sanitized binary packets copied from
+the pinned `Gt06ProtocolDecoderTest`. They cover login, a standard GPS/LBS location,
+the JM-VL03-associated `0xA0` layout, and heartbeat/status framing. A generated
+extended-header form exercises bounded framing and exact response construction. These
+are upstream interoperability fixtures, not captured packets from the target devices
+and not certification evidence. The test-only TCP simulator exercises fragmented
+login/location frames and verifies that the gateway defers the response until
+canonical ingress confirms acceptance.
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs
new file mode 100644
index 000000000..e610691ab
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06Crc16.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Resgrid.Providers.Tracking.Protocols.Gt06
+{
+ internal static class Gt06Crc16
+ {
+ public static ushort Compute(
+ ReadOnlySpan data)
+ {
+ ushort crc = 0xFFFF;
+ foreach (var value in data)
+ {
+ crc ^= value;
+ for (var bit = 0;
+ bit < 8;
+ bit++)
+ {
+ crc = (crc & 1) != 0
+ ? (ushort)((crc >> 1) ^ 0x8408)
+ : (ushort)(crc >> 1);
+ }
+ }
+
+ return (ushort)~crc;
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs
new file mode 100644
index 000000000..edb8ee397
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolModule.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Providers.Tracking.Protocols.Gt06
+{
+ public sealed class Gt06ProtocolModule :
+ ITrackingProtocolModule
+ {
+ private static readonly IReadOnlySet
+ Transports = new HashSet
+ {
+ TrackingSocketTransport.Tcp
+ };
+
+ public string ProtocolKey =>
+ TrackingProtocolKeys.Gt06;
+
+ public IReadOnlySet
+ SupportedTransports => Transports;
+
+ public ITrackingProtocolSession CreateSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.Transport !=
+ TrackingSocketTransport.Tcp)
+ {
+ throw new NotSupportedException(
+ "The bounded GT06/Jimi module supports TCP only.");
+ }
+
+ return new Gt06ProtocolSession(context);
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs
new file mode 100644
index 000000000..407b17eb9
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Gt06/Gt06ProtocolSession.cs
@@ -0,0 +1,726 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols.Gt06
+{
+ public sealed class Gt06ProtocolSession :
+ ITrackingProtocolSession,
+ ITrackingProtocolPositionEnricher
+ {
+ private const ushort ShortHeader = 0x7878;
+ private const ushort ExtendedHeader = 0x7979;
+ private const ushort Tail = 0x0D0A;
+ private const int MinimumDeclaredLength = 5;
+ private const int GpsPayloadLength = 18;
+
+ private const byte LoginType = 0x01;
+ private const byte StatusType = 0x13;
+ private const byte HeartbeatType = 0x23;
+ private const byte GpsLbsStatusType = 0x16;
+ private const byte GpsLbsType = 0x22;
+ private const byte JmVl03GpsType = 0xA0;
+
+ private static readonly IReadOnlySet
+ PositionTypes = new HashSet
+ {
+ GpsLbsStatusType,
+ GpsLbsType,
+ JmVl03GpsType
+ };
+
+ private static readonly IReadOnlySet
+ CommonStatusPositionTypes =
+ new HashSet
+ {
+ GpsLbsStatusType
+ };
+
+ private readonly int _maximumFrameBytes;
+ private bool _loginAccepted;
+
+ public Gt06ProtocolSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.MaxFrameBytes <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(context.MaxFrameBytes));
+
+ _maximumFrameBytes = context.MaxFrameBytes;
+ }
+
+ public ProtocolParseResult Parse(
+ ref ReadOnlySequence input)
+ {
+ if (input.Length < 3)
+ return NeedMore(input);
+
+ var prefix = input.Slice(
+ 0,
+ Math.Min(input.Length, 4))
+ .ToArray();
+ var header = BinaryPrimitives
+ .ReadUInt16BigEndian(prefix);
+ var extended = header == ExtendedHeader;
+ if (header != ShortHeader &&
+ !extended)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "header-invalid");
+ }
+
+ var lengthFieldBytes = extended ? 2 : 1;
+ if (input.Length <
+ 2 + lengthFieldBytes)
+ return NeedMore(input);
+ var declaredLength = extended
+ ? BinaryPrimitives.ReadUInt16BigEndian(
+ prefix.AsSpan(2, 2))
+ : prefix[2];
+ if (declaredLength <
+ MinimumDeclaredLength)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "length-invalid");
+ }
+
+ var frameLength =
+ 2 +
+ lengthFieldBytes +
+ declaredLength +
+ 2;
+ if (frameLength > _maximumFrameBytes)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "frame-too-large");
+ }
+ if (input.Length < frameLength)
+ return NeedMore(input);
+
+ var consumed = input.GetPosition(
+ frameLength);
+ var frame = input.Slice(
+ 0,
+ frameLength)
+ .ToArray();
+ if (BinaryPrimitives.ReadUInt16BigEndian(
+ frame.AsSpan(
+ frame.Length - 2,
+ 2)) != Tail)
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "tail-invalid");
+ }
+
+ var crcOffset = frame.Length - 4;
+ var expectedCrc =
+ BinaryPrimitives.ReadUInt16BigEndian(
+ frame.AsSpan(crcOffset, 2));
+ var actualCrc = Gt06Crc16.Compute(
+ frame.AsSpan(
+ 2,
+ crcOffset - 2));
+ if (actualCrc != expectedCrc)
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "crc-invalid");
+ }
+
+ var typeOffset =
+ 2 + lengthFieldBytes;
+ var serialOffset = frame.Length - 6;
+ if (serialOffset <= typeOffset)
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "payload-length-invalid");
+ }
+
+ var type = frame[typeOffset];
+ var serial = BinaryPrimitives
+ .ReadUInt16BigEndian(
+ frame.AsSpan(
+ serialOffset,
+ 2));
+ var payload = frame.AsSpan(
+ typeOffset + 1,
+ serialOffset -
+ typeOffset -
+ 1);
+ var acknowledgementToken =
+ CreateAcknowledgementToken(
+ extended,
+ type,
+ serial);
+
+ if (type == LoginType)
+ {
+ if (!TryReadImei(
+ payload,
+ out var imei))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "imei-invalid");
+ }
+
+ return Message(
+ consumed,
+ ProtocolParseStatus.Login,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Login,
+ ExternalIdentifier = imei,
+ AcknowledgementToken =
+ acknowledgementToken,
+ RequiresResponse = true
+ });
+ }
+
+ if (!_loginAccepted)
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.CloseSession,
+ "login-required");
+ }
+
+ if (type == StatusType ||
+ type == HeartbeatType)
+ {
+ return Message(
+ consumed,
+ ProtocolParseStatus.Heartbeat,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Heartbeat,
+ AcknowledgementToken =
+ acknowledgementToken,
+ RequiresResponse = true
+ });
+ }
+
+ if (!PositionTypes.Contains(type))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Unsupported,
+ "message-type-unsupported");
+ }
+
+ if (!TryParsePosition(
+ type,
+ payload,
+ out var position,
+ out var metadata))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "gps-payload-invalid");
+ }
+
+ position.EventId = CreateEventId(frame);
+ return Message(
+ consumed,
+ ProtocolParseStatus.Positions,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Positions,
+ Positions =
+ new[] { position },
+ ProtocolData =
+ new Gt06ProtocolData(metadata),
+ AcknowledgementToken =
+ acknowledgementToken,
+ RequiresResponse = true
+ });
+ }
+
+ public ReadOnlyMemory BuildResponse(
+ ProtocolMessage message,
+ TrackingAcceptance acceptance)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (acceptance == null)
+ throw new ArgumentNullException(nameof(acceptance));
+
+ var accepted =
+ acceptance.Status ==
+ TrackingAcceptanceStatus.Accepted;
+ if (message.MessageType ==
+ ProtocolMessageType.Login)
+ _loginAccepted = accepted;
+
+ if (!accepted ||
+ message.AcknowledgementToken.Length != 4)
+ return ReadOnlyMemory.Empty;
+ if (message.MessageType ==
+ ProtocolMessageType.Positions &&
+ (message.Positions == null ||
+ acceptance.AcceptedPositions !=
+ message.Positions.Count))
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ var token =
+ message.AcknowledgementToken.Span;
+ return BuildAcknowledgement(
+ token[0] == 1,
+ token[1],
+ BinaryPrimitives.ReadUInt16BigEndian(
+ token.Slice(2, 2)));
+ }
+
+ public void EnrichPositions(
+ ProtocolMessage message,
+ UnitTrackingDevice device)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (device == null)
+ throw new ArgumentNullException(nameof(device));
+ if (message.ProtocolData is not
+ Gt06ProtocolData protocolData ||
+ message.Positions == null ||
+ message.Positions.Count != 1)
+ return;
+
+ var position = message.Positions.Single();
+ var alarmCode = MapAlarm(
+ protocolData.Metadata.Alarm,
+ string.Equals(
+ device.ModelKey,
+ "jimi-vl103m",
+ StringComparison.OrdinalIgnoreCase));
+ if (!string.IsNullOrEmpty(alarmCode))
+ position.AlarmCode = alarmCode;
+
+ var profile = UnitTrackingCatalog.GetProfile(
+ device.ModelKey);
+ if (profile == null ||
+ !string.Equals(
+ profile.ProtocolKey,
+ TrackingProtocolKeys.Gt06,
+ StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(
+ device.ProtocolKey,
+ TrackingProtocolKeys.Gt06,
+ StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var levelEncoded = string.Equals(
+ profile.DecoderVariant,
+ "gt06-vl103-bounded",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(
+ profile.DecoderVariant,
+ "gt06-jm-vl03-a0-bounded",
+ StringComparison.OrdinalIgnoreCase);
+ var nativeEncoded = string.Equals(
+ profile.DecoderVariant,
+ "gt06-jm-vl01-unverified",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(
+ profile.DecoderVariant,
+ "gt06-jm-vl02-unverified",
+ StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(
+ profile.DecoderVariant,
+ "gt06-jm-vl04-unverified",
+ StringComparison.OrdinalIgnoreCase);
+ if (protocolData.Metadata.Battery.HasValue)
+ {
+ position.BatteryPercent = levelEncoded
+ ? BatteryLevelToPercent(
+ protocolData.Metadata.Battery.Value)
+ : nativeEncoded
+ ? BatteryNativeToPercent(
+ protocolData.Metadata.Battery.Value)
+ : null;
+ }
+ if (protocolData.Metadata.Signal.HasValue)
+ {
+ position.SignalPercent = levelEncoded
+ ? SignalLevelToPercent(
+ protocolData.Metadata.Signal.Value)
+ : nativeEncoded
+ ? SignalNativeToPercent(
+ protocolData.Metadata.Signal.Value)
+ : null;
+ }
+ }
+
+ private static bool TryParsePosition(
+ byte type,
+ ReadOnlySpan payload,
+ out CanonicalTrackingPosition position,
+ out Gt06PositionMetadata metadata)
+ {
+ position = null;
+ metadata = new Gt06PositionMetadata();
+ if (payload.Length < GpsPayloadLength ||
+ !TryReadTimestamp(
+ payload.Slice(0, 6),
+ out var timestamp))
+ return false;
+
+ var satellites = payload[6] & 0x0F;
+ var latitude =
+ BinaryPrimitives.ReadUInt32BigEndian(
+ payload.Slice(7, 4)) /
+ 1800000m;
+ var longitude =
+ BinaryPrimitives.ReadUInt32BigEndian(
+ payload.Slice(11, 4)) /
+ 1800000m;
+ var speedKilometersPerHour = payload[15];
+ var flags =
+ BinaryPrimitives.ReadUInt16BigEndian(
+ payload.Slice(16, 2));
+ if ((flags & (1 << 10)) == 0)
+ latitude = -latitude;
+ if ((flags & (1 << 11)) != 0)
+ longitude = -longitude;
+ if (latitude < -90m ||
+ latitude > 90m ||
+ longitude < -180m ||
+ longitude > 180m)
+ return false;
+
+ position = new CanonicalTrackingPosition
+ {
+ TimestampUtc = timestamp,
+ ReceivedOnUtc = DateTime.UtcNow,
+ Latitude = latitude,
+ Longitude = longitude,
+ SpeedMetersPerSecond =
+ speedKilometersPerHour / 3.6m,
+ HeadingDegrees = flags & 0x03FF,
+ Satellites = satellites,
+ IsMoving =
+ speedKilometersPerHour > 0,
+ TimestampSource =
+ TrackingTimestampSource.Device,
+ IsValidFix =
+ (flags & (1 << 12)) != 0
+ };
+ if ((flags & (1 << 14)) != 0)
+ {
+ position.Ignition =
+ (flags & (1 << 15)) != 0;
+ }
+
+ if (type == JmVl03GpsType &&
+ payload.Length >= 36)
+ {
+ position.Ignition = payload[33] > 0;
+ }
+ else if (CommonStatusPositionTypes.Contains(
+ type) &&
+ payload.Length >= 30)
+ {
+ var status = payload[26];
+ position.Ignition =
+ (status & (1 << 1)) != 0;
+ metadata.Battery = payload[27];
+ metadata.Signal = payload[28];
+ metadata.Alarm = payload[29];
+ position.AlarmCode =
+ MapStatusAlarm(status);
+ }
+ else if (payload.Length == 29)
+ {
+ position.Ignition = payload[26] > 0;
+ }
+
+ return true;
+ }
+
+ private static decimal? BatteryLevelToPercent(
+ byte value)
+ {
+ return value <= 6
+ ? value * 100m / 6m
+ : (decimal?)null;
+ }
+
+ private static int? SignalLevelToPercent(
+ byte value)
+ {
+ return value <= 4
+ ? value * 25
+ : (int?)null;
+ }
+
+ private static decimal? BatteryNativeToPercent(
+ byte value)
+ {
+ return value <= 100
+ ? value
+ : (decimal?)null;
+ }
+
+ private static int? SignalNativeToPercent(
+ byte value)
+ {
+ return value <= 100
+ ? value
+ : (int?)null;
+ }
+
+ private static string MapStatusAlarm(byte status)
+ {
+ return ((status >> 3) & 0x07) switch
+ {
+ 1 => "vibration",
+ 2 => "powerCut",
+ 3 => "lowBattery",
+ 4 => "sos",
+ 6 => "geofence",
+ 7 => "removing",
+ _ => null
+ };
+ }
+
+ private static string MapAlarm(
+ byte? value,
+ bool modelVl)
+ {
+ return value switch
+ {
+ 0x01 => "sos",
+ 0x02 => "powerCut",
+ 0x03 => "vibration",
+ 0x04 => "geofenceEnter",
+ 0x05 => "geofenceExit",
+ 0x06 => "overspeed",
+ 0x09 => modelVl ? "tow" : "vibration",
+ 0x0E or 0x0F => "lowBattery",
+ 0x11 => "powerOff",
+ 0x0C or 0x13 or 0x25 =>
+ "tampering",
+ 0x14 => "door",
+ 0x19 => "lowBattery",
+ 0x1A or 0x27 => "braking",
+ 0x1B or 0x2A or 0x2B or 0x2E =>
+ "cornering",
+ 0x23 => "fallDown",
+ 0x26 => "acceleration",
+ 0x2C => "accident",
+ 0x30 => modelVl ? "braking" : "jamming",
+ _ => null
+ };
+ }
+
+ private static bool TryReadImei(
+ ReadOnlySpan payload,
+ out string imei)
+ {
+ imei = null;
+ if (payload.Length < 8)
+ return false;
+
+ var bcd = Convert.ToHexString(
+ payload.Slice(0, 8))
+ .ToLowerInvariant();
+ if (bcd.Length != 16 ||
+ bcd[0] != '0')
+ return false;
+
+ var candidate = bcd.Substring(1);
+ if (candidate.Any(
+ value =>
+ value < '0' ||
+ value > '9'))
+ return false;
+
+ imei = candidate;
+ return true;
+ }
+
+ private static bool TryReadTimestamp(
+ ReadOnlySpan value,
+ out DateTime timestamp)
+ {
+ timestamp = default;
+ if (value.Length != 6)
+ return false;
+
+ try
+ {
+ timestamp = new DateTime(
+ 2000 + value[0],
+ value[1],
+ value[2],
+ value[3],
+ value[4],
+ value[5],
+ DateTimeKind.Utc);
+ return true;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+ }
+
+ private static byte[] CreateAcknowledgementToken(
+ bool extended,
+ byte type,
+ ushort serial)
+ {
+ var token = new byte[4];
+ token[0] = extended ? (byte)1 : (byte)0;
+ token[1] = type;
+ BinaryPrimitives.WriteUInt16BigEndian(
+ token.AsSpan(2),
+ serial);
+ return token;
+ }
+
+ private static byte[] BuildAcknowledgement(
+ bool extended,
+ byte type,
+ ushort serial)
+ {
+ var lengthFieldBytes = extended ? 2 : 1;
+ var response = new byte[
+ 2 +
+ lengthFieldBytes +
+ MinimumDeclaredLength +
+ 2];
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response,
+ extended
+ ? ExtendedHeader
+ : ShortHeader);
+ if (extended)
+ {
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response.AsSpan(2, 2),
+ MinimumDeclaredLength);
+ }
+ else
+ {
+ response[2] =
+ MinimumDeclaredLength;
+ }
+
+ var typeOffset =
+ 2 + lengthFieldBytes;
+ response[typeOffset] = type;
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response.AsSpan(
+ typeOffset + 1,
+ 2),
+ serial);
+ var crcOffset = response.Length - 4;
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response.AsSpan(crcOffset, 2),
+ Gt06Crc16.Compute(
+ response.AsSpan(
+ 2,
+ crcOffset - 2)));
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response.AsSpan(
+ response.Length - 2,
+ 2),
+ Tail);
+ return response;
+ }
+
+ private static string CreateEventId(
+ ReadOnlySpan frame)
+ {
+ return "gt06:" +
+ Convert.ToHexString(
+ SHA256.HashData(frame))
+ .ToLowerInvariant();
+ }
+
+ private static ProtocolParseResult NeedMore(
+ ReadOnlySequence input)
+ {
+ return new ProtocolParseResult
+ {
+ Status =
+ ProtocolParseStatus.NeedMoreData,
+ Consumed = input.Start,
+ Examined = input.End
+ };
+ }
+
+ private static ProtocolParseResult Message(
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ ProtocolMessage message)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ Message = message
+ };
+ }
+
+ private static ProtocolParseResult Terminal(
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ string reasonCode)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ ReasonCode = reasonCode
+ };
+ }
+ }
+
+ internal sealed class Gt06ProtocolData
+ {
+ public Gt06ProtocolData(
+ Gt06PositionMetadata metadata)
+ {
+ Metadata = metadata ??
+ throw new ArgumentNullException(
+ nameof(metadata));
+ }
+
+ public Gt06PositionMetadata Metadata { get; }
+ }
+
+ internal sealed class Gt06PositionMetadata
+ {
+ public byte? Alarm { get; set; }
+ public byte? Battery { get; set; }
+ public byte? Signal { get; set; }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs b/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs
new file mode 100644
index 000000000..812556b79
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/ITrackingProtocolModuleRegistry.cs
@@ -0,0 +1,18 @@
+using System.Collections.Generic;
+
+namespace Resgrid.Providers.Tracking.Protocols
+{
+ public interface ITrackingProtocolModuleRegistry
+ {
+ IReadOnlyCollection Modules { get; }
+
+ bool TryResolve(
+ string protocolKey,
+ TrackingSocketTransport transport,
+ out ITrackingProtocolModule module);
+
+ ITrackingProtocolModule Resolve(
+ string protocolKey,
+ TrackingSocketTransport transport);
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs
new file mode 100644
index 000000000..45edccd40
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolModule.cs
@@ -0,0 +1,36 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Providers.Tracking.Protocols.Queclink
+{
+ public sealed class QueclinkProtocolModule :
+ ITrackingProtocolModule
+ {
+ private static readonly IReadOnlySet
+ Transports = new HashSet
+ {
+ TrackingSocketTransport.Tcp
+ };
+
+ public string ProtocolKey =>
+ TrackingProtocolKeys.Queclink;
+
+ public IReadOnlySet
+ SupportedTransports => Transports;
+
+ public ITrackingProtocolSession CreateSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.Transport !=
+ TrackingSocketTransport.Tcp)
+ {
+ throw new NotSupportedException(
+ "The bounded Queclink @Track module supports TCP only.");
+ }
+
+ return new QueclinkProtocolSession(context);
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs
new file mode 100644
index 000000000..e1970d123
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Queclink/QueclinkProtocolSession.cs
@@ -0,0 +1,669 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols.Queclink
+{
+ public sealed class QueclinkProtocolSession :
+ ITrackingProtocolSession
+ {
+ private const int MaximumFields = 128;
+ private const int MaximumFieldLength = 256;
+ private const int MaximumPositionsPerFrame = 16;
+ private const decimal MaximumSpeedKilometersPerHour =
+ 1000m;
+
+ private static readonly IReadOnlySet
+ LocationReportTypes =
+ new HashSet(
+ new[]
+ {
+ "CTN", "DOG", "ERI", "FRI",
+ "GEO", "HBM", "IDL", "IGF",
+ "IGL", "IGN", "JDR", "JDS",
+ "NMR", "RTL", "SOS", "SPD",
+ "STR", "STT", "SWG", "TEM",
+ "TMP", "TOW", "VGF", "VGN"
+ },
+ StringComparer.Ordinal);
+
+ private static readonly IReadOnlySet
+ StatusReportTypes =
+ new HashSet(
+ new[]
+ {
+ "BPL", "DIS", "EPF", "EPN",
+ "INF", "MPF", "MPN", "PFA",
+ "PNA", "VER"
+ },
+ StringComparer.Ordinal);
+
+ private static readonly IReadOnlyDictionary
+ AlarmCodes =
+ new Dictionary(
+ StringComparer.Ordinal)
+ {
+ ["BPL"] = "lowBattery",
+ ["EPF"] = "powerCut",
+ ["EPN"] = "powerRestored",
+ ["HBM"] = "harshMotion",
+ ["IDL"] = "idle",
+ ["JDR"] = "jamming",
+ ["JDS"] = "jamming",
+ ["MPF"] = "powerCut",
+ ["MPN"] = "powerRestored",
+ ["PFA"] = "powerOff",
+ ["PNA"] = "powerOn",
+ ["SOS"] = "sos",
+ ["SPD"] = "overspeed",
+ ["STT"] = "movement",
+ ["SWG"] = "geofence",
+ ["TEM"] = "temperature",
+ ["TMP"] = "temperature",
+ ["TOW"] = "tow"
+ };
+
+ private readonly int _maximumFrameBytes;
+
+ public QueclinkProtocolSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.MaxFrameBytes <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(context.MaxFrameBytes));
+
+ _maximumFrameBytes = context.MaxFrameBytes;
+ }
+
+ public ProtocolParseResult Parse(
+ ref ReadOnlySequence input)
+ {
+ if (input.IsEmpty)
+ return NeedMore(input);
+
+ var frameLength = FindFrameLength(input);
+ if (frameLength == 0)
+ {
+ return input.Length >= _maximumFrameBytes
+ ? Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "frame-too-large")
+ : NeedMore(input);
+ }
+ if (frameLength > _maximumFrameBytes)
+ {
+ return Terminal(
+ input.GetPosition(frameLength),
+ ProtocolParseStatus.Malformed,
+ "frame-too-large");
+ }
+
+ var consumed = input.GetPosition(frameLength);
+ var frame = input.Slice(0, frameLength).ToArray();
+ var contentLength = frame.Length - 1;
+ while (contentLength > 0 &&
+ (frame[contentLength - 1] == (byte)'\r' ||
+ frame[contentLength - 1] == (byte)'\n'))
+ {
+ contentLength--;
+ }
+
+ if (contentLength == 0 ||
+ !IsPrintableAscii(
+ frame.AsSpan(0, contentLength)))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "ascii-frame-invalid");
+ }
+
+ var sentence = Encoding.ASCII.GetString(
+ frame,
+ 0,
+ contentLength);
+ var fields = sentence.Split(
+ ',',
+ StringSplitOptions.None);
+ if (fields.Length < 3 ||
+ fields.Length > MaximumFields ||
+ fields.Any(
+ field =>
+ field.Length >
+ MaximumFieldLength))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "field-count-invalid");
+ }
+
+ if (!TryReadHeader(
+ fields[0],
+ out var prefix,
+ out var reportType) ||
+ !IsProtocolVersion(fields[1]) ||
+ !IsImei(fields[2]))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "header-invalid");
+ }
+
+ if (prefix == "ACK" &&
+ reportType == "HBD")
+ {
+ var acknowledgement =
+ Encoding.ASCII.GetBytes(
+ $"+SACK:GTHBD,{fields[1]},{fields[^1]}$");
+ return Message(
+ consumed,
+ ProtocolParseStatus.Heartbeat,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Heartbeat,
+ ExternalIdentifier = fields[2],
+ AcknowledgementToken =
+ acknowledgement,
+ RequiresResponse = true
+ });
+ }
+
+ if (prefix != "RESP" &&
+ prefix != "BUFF")
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Unsupported,
+ "prefix-unsupported");
+ }
+ if (!LocationReportTypes.Contains(reportType) &&
+ !StatusReportTypes.Contains(reportType))
+ {
+ return Terminal(
+ consumed,
+ ProtocolParseStatus.Unsupported,
+ "report-type-unsupported");
+ }
+
+ var positions = ParsePositions(
+ fields,
+ reportType,
+ frame.AsSpan(0, contentLength));
+ if (positions.Count == 0)
+ {
+ return Message(
+ consumed,
+ ProtocolParseStatus.Heartbeat,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Heartbeat,
+ ExternalIdentifier = fields[2],
+ RequiresResponse = false
+ });
+ }
+
+ return Message(
+ consumed,
+ ProtocolParseStatus.Positions,
+ new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Positions,
+ ExternalIdentifier = fields[2],
+ Positions = positions,
+ RequiresResponse = false
+ });
+ }
+
+ public ReadOnlyMemory BuildResponse(
+ ProtocolMessage message,
+ TrackingAcceptance acceptance)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (acceptance == null)
+ throw new ArgumentNullException(nameof(acceptance));
+ if (message.MessageType !=
+ ProtocolMessageType.Heartbeat ||
+ acceptance.Status !=
+ TrackingAcceptanceStatus.Accepted)
+ {
+ return ReadOnlyMemory.Empty;
+ }
+
+ return message.AcknowledgementToken;
+ }
+
+ private static IReadOnlyCollection
+ ParsePositions(
+ IReadOnlyList fields,
+ string reportType,
+ ReadOnlySpan rawFrame)
+ {
+ var positions =
+ new List();
+ var receivedOn = DateTime.UtcNow;
+ for (var index = 3;
+ index + 6 < fields.Count - 1 &&
+ positions.Count < MaximumPositionsPerFrame;
+ index++)
+ {
+ if (!TryParseLocation(
+ fields,
+ index,
+ receivedOn,
+ out var position))
+ {
+ continue;
+ }
+
+ ApplyReportMetadata(
+ position,
+ reportType,
+ fields,
+ index);
+ position.EventId = CreateEventId(
+ rawFrame,
+ positions.Count);
+ positions.Add(position);
+ index += 6;
+ }
+
+ return positions;
+ }
+
+ private static bool TryParseLocation(
+ IReadOnlyList fields,
+ int index,
+ DateTime receivedOn,
+ out CanonicalTrackingPosition position)
+ {
+ position = null;
+ if (!TryParseOptionalDecimal(
+ fields[index],
+ 0m,
+ 99m,
+ out var hdop) ||
+ !TryParseOptionalDecimal(
+ fields[index + 1],
+ 0m,
+ MaximumSpeedKilometersPerHour,
+ out var speed) ||
+ !TryParseOptionalDecimal(
+ fields[index + 2],
+ 0m,
+ 360m,
+ out var heading) ||
+ !TryParseOptionalDecimal(
+ fields[index + 3],
+ -20000m,
+ 100000m,
+ out var altitude) ||
+ !TryParseCoordinate(
+ fields[index + 4],
+ -180m,
+ 180m,
+ out var longitude) ||
+ !TryParseCoordinate(
+ fields[index + 5],
+ -90m,
+ 90m,
+ out var latitude) ||
+ !TryParseTimestamp(
+ fields[index + 6],
+ out var timestamp))
+ {
+ return false;
+ }
+
+ position = new CanonicalTrackingPosition
+ {
+ TimestampUtc = timestamp,
+ ReceivedOnUtc = receivedOn,
+ Latitude = latitude,
+ Longitude = longitude,
+ Hdop = hdop,
+ AltitudeMeters = altitude,
+ SpeedMetersPerSecond =
+ speed.HasValue
+ ? speed.Value / 3.6m
+ : null,
+ HeadingDegrees = heading,
+ IsMoving =
+ speed.HasValue
+ ? speed.Value > 0m
+ : null,
+ TimestampSource =
+ TrackingTimestampSource.Device,
+ IsValidFix = true
+ };
+ return true;
+ }
+
+ private static void ApplyReportMetadata(
+ CanonicalTrackingPosition position,
+ string reportType,
+ IReadOnlyList fields,
+ int locationIndex)
+ {
+ if (AlarmCodes.TryGetValue(
+ reportType,
+ out var alarmCode))
+ position.AlarmCode = alarmCode;
+
+ if (reportType == "IGN" ||
+ reportType == "VGN")
+ position.Ignition = true;
+ else if (reportType == "IGF" ||
+ reportType == "VGF")
+ position.Ignition = false;
+
+ if (reportType == "STT" ||
+ reportType == "HBM")
+ position.IsMoving = true;
+
+ for (var index = locationIndex - 1;
+ index >= 3 &&
+ index >= locationIndex - 5;
+ index--)
+ {
+ if (!int.TryParse(
+ fields[index],
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var millivolts) ||
+ millivolts <= 10 ||
+ millivolts > 100000)
+ continue;
+
+ position.ExternalPowerVolts =
+ millivolts / 1000m;
+ break;
+ }
+
+ for (var index = locationIndex + 7;
+ index + 1 < fields.Count - 1;
+ index++)
+ {
+ if (!IsDeviceStatus(fields[index + 1]) ||
+ !int.TryParse(
+ fields[index],
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out var battery) ||
+ battery < 0 ||
+ battery > 100)
+ {
+ continue;
+ }
+
+ position.BatteryPercent = battery;
+ ApplyDeviceStatus(
+ position,
+ fields[index + 1]);
+ break;
+ }
+ }
+
+ private static void ApplyDeviceStatus(
+ CanonicalTrackingPosition position,
+ string status)
+ {
+ if (status.Length != 6 ||
+ !int.TryParse(
+ status.Substring(0, 2),
+ NumberStyles.HexNumber,
+ CultureInfo.InvariantCulture,
+ out var ignition))
+ return;
+
+ if ((ignition & (1 << 4)) != 0)
+ position.Ignition = false;
+ else if ((ignition & (1 << 5)) != 0)
+ position.Ignition = true;
+ }
+
+ private static bool TryReadHeader(
+ string header,
+ out string prefix,
+ out string reportType)
+ {
+ prefix = null;
+ reportType = null;
+ if (header?.Length == 10 &&
+ header.StartsWith(
+ "+ACK:GT",
+ StringComparison.Ordinal))
+ {
+ prefix = "ACK";
+ reportType = header.Substring(7, 3);
+ return true;
+ }
+ if (header?.Length != 11 ||
+ header[0] != '+' ||
+ header[5] != ':' ||
+ header[6] != 'G' ||
+ header[7] != 'T')
+ return false;
+
+ prefix = header.Substring(1, 4);
+ reportType = header.Substring(8, 3);
+ return prefix == "RESP" ||
+ prefix == "BUFF";
+ }
+
+ private static bool IsProtocolVersion(
+ string value)
+ {
+ return value != null &&
+ (value.Length == 6 ||
+ value.Length == 10) &&
+ value.All(IsHexCharacter);
+ }
+
+ private static bool IsImei(string value)
+ {
+ return value?.Length == 15 &&
+ value.All(
+ character =>
+ character >= '0' &&
+ character <= '9');
+ }
+
+ private static bool IsDeviceStatus(string value)
+ {
+ return value != null &&
+ (value.Length == 2 ||
+ value.Length == 6) &&
+ value.All(IsHexCharacter);
+ }
+
+ private static bool IsHexCharacter(char value)
+ {
+ return value >= '0' &&
+ value <= '9' ||
+ value >= 'A' &&
+ value <= 'F' ||
+ value >= 'a' &&
+ value <= 'f';
+ }
+
+ private static bool TryParseOptionalDecimal(
+ string value,
+ decimal minimum,
+ decimal maximum,
+ out decimal? parsed)
+ {
+ parsed = null;
+ if (string.IsNullOrEmpty(value))
+ return true;
+ if (!TryParseRequiredDecimal(
+ value,
+ minimum,
+ maximum,
+ out var required))
+ return false;
+
+ parsed = required;
+ return true;
+ }
+
+ private static bool TryParseRequiredDecimal(
+ string value,
+ decimal minimum,
+ decimal maximum,
+ out decimal parsed)
+ {
+ return decimal.TryParse(
+ value,
+ NumberStyles.AllowLeadingSign |
+ NumberStyles.AllowDecimalPoint,
+ CultureInfo.InvariantCulture,
+ out parsed) &&
+ parsed >= minimum &&
+ parsed <= maximum;
+ }
+
+ private static bool TryParseCoordinate(
+ string value,
+ decimal minimum,
+ decimal maximum,
+ out decimal parsed)
+ {
+ parsed = 0;
+ var separator = value?.LastIndexOf('.') ?? -1;
+ if (separator <= 0 ||
+ value.Length - separator - 1 != 6)
+ return false;
+
+ for (var index = separator + 1;
+ index < value.Length;
+ index++)
+ {
+ if (value[index] < '0' ||
+ value[index] > '9')
+ return false;
+ }
+
+ return TryParseRequiredDecimal(
+ value,
+ minimum,
+ maximum,
+ out parsed);
+ }
+
+ private static bool TryParseTimestamp(
+ string value,
+ out DateTime timestamp)
+ {
+ return DateTime.TryParseExact(
+ value,
+ "yyyyMMddHHmmss",
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal |
+ DateTimeStyles.AdjustToUniversal,
+ out timestamp);
+ }
+
+ private static bool IsPrintableAscii(
+ ReadOnlySpan value)
+ {
+ foreach (var item in value)
+ {
+ if (item < 0x20 ||
+ item > 0x7E)
+ return false;
+ }
+
+ return true;
+ }
+
+ private static long FindFrameLength(
+ ReadOnlySequence input)
+ {
+ var reader = new SequenceReader(input);
+ long length = 0;
+ while (reader.TryRead(out var value))
+ {
+ length++;
+ if (value == (byte)'$' ||
+ value == 0)
+ return length;
+ }
+
+ return 0;
+ }
+
+ private static string CreateEventId(
+ ReadOnlySpan frame,
+ int positionIndex)
+ {
+ using var hash = IncrementalHash.CreateHash(
+ HashAlgorithmName.SHA256);
+ hash.AppendData(frame);
+ Span indexBytes = stackalloc byte[4];
+ BinaryPrimitives.WriteInt32BigEndian(
+ indexBytes,
+ positionIndex);
+ hash.AppendData(indexBytes);
+ return "queclink:" +
+ Convert.ToHexString(
+ hash.GetHashAndReset())
+ .ToLowerInvariant();
+ }
+
+ private static ProtocolParseResult NeedMore(
+ ReadOnlySequence input)
+ {
+ return new ProtocolParseResult
+ {
+ Status =
+ ProtocolParseStatus.NeedMoreData,
+ Consumed = input.Start,
+ Examined = input.End
+ };
+ }
+
+ private static ProtocolParseResult Message(
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ ProtocolMessage message)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ Message = message
+ };
+ }
+
+ private static ProtocolParseResult Terminal(
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ string reasonCode)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ ReasonCode = reasonCode
+ };
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs
new file mode 100644
index 000000000..358afe61f
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolModule.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Providers.Tracking.Protocols.Teltonika
+{
+ public sealed class TeltonikaCodec8ProtocolModule :
+ ITrackingProtocolModule
+ {
+ public const string Key = "teltonika-codec8";
+
+ private static readonly IReadOnlySet
+ Transports = new HashSet
+ {
+ TrackingSocketTransport.Tcp,
+ TrackingSocketTransport.Udp
+ };
+
+ public string ProtocolKey => Key;
+ public IReadOnlySet
+ SupportedTransports => Transports;
+
+ public ITrackingProtocolSession CreateSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.Transport ==
+ TrackingSocketTransport.Tcp)
+ {
+ return new TeltonikaCodec8ProtocolSession(
+ context);
+ }
+ if (context.Transport ==
+ TrackingSocketTransport.Udp)
+ {
+ return new TeltonikaCodec8UdpProtocolSession(
+ context);
+ }
+
+ throw new NotSupportedException(
+ "Teltonika Codec8/8E supports TCP and UDP.");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs
new file mode 100644
index 000000000..04d16f30f
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8ProtocolSession.cs
@@ -0,0 +1,738 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols.Teltonika
+{
+ public sealed class TeltonikaCodec8ProtocolSession :
+ ITrackingProtocolSession,
+ ITrackingProtocolPositionEnricher
+ {
+ private const byte Codec8 = 0x08;
+ private const byte Codec8Extended = 0x8E;
+ private const int ImeiLength = 15;
+ private const int AvlHeaderLength = 8;
+ private const int AvlCrcLength = 4;
+ private const int MinimumDataFieldLength = 3;
+ private const int MaximumIoElementsPerRecord = 1024;
+ private const int MaximumVariableIoValueBytes = 4096;
+ private const decimal CoordinateScale = 10000000m;
+
+ private readonly int _maximumFrameBytes;
+ private bool _loginAccepted;
+
+ public TeltonikaCodec8ProtocolSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.MaxFrameBytes <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(context.MaxFrameBytes));
+
+ _maximumFrameBytes = context.MaxFrameBytes;
+ }
+
+ public ProtocolParseResult Parse(
+ ref ReadOnlySequence input)
+ {
+ return _loginAccepted
+ ? ParseAvlPacket(input)
+ : ParseLogin(input);
+ }
+
+ public ReadOnlyMemory BuildResponse(
+ ProtocolMessage message,
+ TrackingAcceptance acceptance)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (acceptance == null)
+ throw new ArgumentNullException(nameof(acceptance));
+
+ if (message.MessageType == ProtocolMessageType.Login)
+ {
+ _loginAccepted =
+ acceptance.Status ==
+ TrackingAcceptanceStatus.Accepted;
+ return new byte[]
+ {
+ _loginAccepted ? (byte)0x01 : (byte)0x00
+ };
+ }
+
+ if (message.MessageType !=
+ ProtocolMessageType.Positions)
+ return ReadOnlyMemory.Empty;
+
+ var acceptedRecords = 0;
+ if (acceptance.Status ==
+ TrackingAcceptanceStatus.Accepted &&
+ message.Positions != null &&
+ acceptance.AcceptedPositions ==
+ message.Positions.Count)
+ {
+ acceptedRecords =
+ acceptance.AcceptedPositions;
+ }
+
+ var response = new byte[sizeof(int)];
+ BinaryPrimitives.WriteInt32BigEndian(
+ response,
+ acceptedRecords);
+ return response;
+ }
+
+ public void EnrichPositions(
+ ProtocolMessage message,
+ UnitTrackingDevice device)
+ {
+ TeltonikaIoMapper.EnrichPositions(
+ message,
+ device);
+ }
+
+ private ProtocolParseResult ParseLogin(
+ ReadOnlySequence input)
+ {
+ if (input.Length < sizeof(ushort))
+ return NeedMore(input);
+
+ var reader = new SequenceReader(input);
+ if (!TryReadUInt16(
+ ref reader,
+ out var imeiLength))
+ return NeedMore(input);
+ if (imeiLength != ImeiLength)
+ {
+ return Terminal(
+ input,
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "imei-length-invalid");
+ }
+ if (reader.Remaining < imeiLength)
+ return NeedMore(input);
+
+ var imeiBytes = input
+ .Slice(reader.Position, imeiLength)
+ .ToArray();
+ for (var index = 0;
+ index < imeiBytes.Length;
+ index++)
+ {
+ if (imeiBytes[index] < (byte)'0' ||
+ imeiBytes[index] > (byte)'9')
+ {
+ return Terminal(
+ input,
+ input.GetPosition(
+ sizeof(ushort) + imeiLength),
+ ProtocolParseStatus.Malformed,
+ "imei-invalid");
+ }
+ }
+
+ var consumed = input.GetPosition(
+ sizeof(ushort) + imeiLength);
+ return new ProtocolParseResult
+ {
+ Status = ProtocolParseStatus.Login,
+ Consumed = consumed,
+ Examined = consumed,
+ Message = new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Login,
+ ExternalIdentifier =
+ Encoding.ASCII.GetString(
+ imeiBytes),
+ RequiresResponse = true
+ }
+ };
+ }
+
+ private ProtocolParseResult ParseAvlPacket(
+ ReadOnlySequence input)
+ {
+ if (input.Length < AvlHeaderLength)
+ return NeedMore(input);
+
+ var headerReader =
+ new SequenceReader(input);
+ if (!TryReadUInt32(
+ ref headerReader,
+ out var preamble) ||
+ !TryReadUInt32(
+ ref headerReader,
+ out var dataFieldLength))
+ return NeedMore(input);
+ if (preamble != 0)
+ {
+ return Terminal(
+ input,
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "preamble-invalid");
+ }
+ if (dataFieldLength <
+ MinimumDataFieldLength)
+ {
+ return Terminal(
+ input,
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "data-length-invalid");
+ }
+
+ var frameLength =
+ (long)AvlHeaderLength +
+ dataFieldLength +
+ AvlCrcLength;
+ if (frameLength > _maximumFrameBytes)
+ {
+ return Terminal(
+ input,
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "frame-too-large");
+ }
+ if (input.Length < frameLength)
+ return NeedMore(input);
+
+ var consumed = input.GetPosition(
+ frameLength);
+ var dataField = input.Slice(
+ AvlHeaderLength,
+ dataFieldLength);
+ if (!TryReadExpectedCrc(
+ input,
+ dataFieldLength,
+ out var expectedCrc) ||
+ TeltonikaCrc16.Compute(dataField) !=
+ expectedCrc)
+ {
+ return Terminal(
+ input,
+ consumed,
+ ProtocolParseStatus.Malformed,
+ "crc-invalid");
+ }
+
+ var avlData = ParseDataField(dataField);
+ if (avlData.Status !=
+ ProtocolParseStatus.Positions)
+ {
+ return Terminal(
+ input,
+ consumed,
+ avlData.Status,
+ avlData.ReasonCode);
+ }
+
+ return new ProtocolParseResult
+ {
+ Status = ProtocolParseStatus.Positions,
+ Consumed = consumed,
+ Examined = consumed,
+ Message = new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Positions,
+ Positions = avlData.Positions,
+ ProtocolData = avlData.ProtocolData,
+ AcknowledgementToken =
+ new byte[] { avlData.RecordCount },
+ RequiresResponse = true
+ }
+ };
+ }
+
+ internal static TeltonikaAvlDataParseResult
+ ParseDataField(
+ ReadOnlySequence dataField)
+ {
+ var dataReader =
+ new SequenceReader(dataField);
+ if (!dataReader.TryRead(out var codec))
+ {
+ return TeltonikaAvlDataParseResult.Failure(
+ ProtocolParseStatus.Malformed,
+ "codec-required");
+ }
+ if (codec != Codec8 &&
+ codec != Codec8Extended)
+ {
+ return TeltonikaAvlDataParseResult.Failure(
+ ProtocolParseStatus.Unsupported,
+ "codec-unsupported");
+ }
+ if (!dataReader.TryRead(
+ out var recordCount) ||
+ recordCount == 0)
+ {
+ return TeltonikaAvlDataParseResult.Failure(
+ ProtocolParseStatus.Malformed,
+ "record-count-invalid");
+ }
+
+ var positions =
+ new List(
+ recordCount);
+ var ioRecords =
+ new List>(
+ recordCount);
+ var receivedOn = DateTime.UtcNow;
+ for (var recordIndex = 0;
+ recordIndex < recordCount;
+ recordIndex++)
+ {
+ var recordStart = dataReader.Position;
+ if (!TryReadPosition(
+ ref dataReader,
+ codec,
+ receivedOn,
+ out var position,
+ out var ioValues))
+ {
+ return TeltonikaAvlDataParseResult
+ .Failure(
+ ProtocolParseStatus.Malformed,
+ "record-invalid");
+ }
+
+ position.EventId = CreateEventId(
+ codec,
+ dataField.Slice(
+ recordStart,
+ dataReader.Position));
+ positions.Add(position);
+ ioRecords.Add(ioValues);
+ }
+
+ if (!dataReader.TryRead(
+ out var repeatedRecordCount) ||
+ repeatedRecordCount != recordCount ||
+ dataReader.Remaining != 0)
+ {
+ return TeltonikaAvlDataParseResult.Failure(
+ ProtocolParseStatus.Malformed,
+ "record-count-mismatch");
+ }
+
+ return new TeltonikaAvlDataParseResult
+ {
+ Status = ProtocolParseStatus.Positions,
+ Positions = positions,
+ ProtocolData =
+ new TeltonikaProtocolData(ioRecords),
+ RecordCount = recordCount
+ };
+ }
+
+ private static bool TryReadPosition(
+ ref SequenceReader reader,
+ byte codec,
+ DateTime receivedOn,
+ out CanonicalTrackingPosition position,
+ out IReadOnlyDictionary ioValues)
+ {
+ position = null;
+ ioValues = null;
+ if (!reader.TryReadBigEndian(
+ out long timestampMilliseconds) ||
+ timestampMilliseconds < 0 ||
+ !reader.TryRead(out var priority) ||
+ priority > 2 ||
+ !reader.TryReadBigEndian(
+ out int longitudeValue) ||
+ !reader.TryReadBigEndian(
+ out int latitudeValue) ||
+ !reader.TryReadBigEndian(
+ out short altitude) ||
+ !TryReadUInt16(
+ ref reader,
+ out var angle) ||
+ angle > 360 ||
+ !reader.TryRead(out var satellites) ||
+ !TryReadUInt16(
+ ref reader,
+ out var speedKilometersPerHour))
+ return false;
+
+ DateTime timestamp;
+ try
+ {
+ timestamp = DateTimeOffset
+ .FromUnixTimeMilliseconds(
+ timestampMilliseconds)
+ .UtcDateTime;
+ }
+ catch (ArgumentOutOfRangeException)
+ {
+ return false;
+ }
+
+ var longitude =
+ longitudeValue / CoordinateScale;
+ var latitude =
+ latitudeValue / CoordinateScale;
+ if (longitude < -180m ||
+ longitude > 180m ||
+ latitude < -90m ||
+ latitude > 90m)
+ return false;
+
+ if (!TryReadIoElements(
+ ref reader,
+ codec,
+ out ioValues))
+ return false;
+
+ position = new CanonicalTrackingPosition
+ {
+ TimestampUtc = timestamp,
+ ReceivedOnUtc = receivedOn,
+ Latitude = latitude,
+ Longitude = longitude,
+ AltitudeMeters = altitude,
+ SpeedMetersPerSecond =
+ speedKilometersPerHour / 3.6m,
+ HeadingDegrees = angle,
+ Satellites = satellites,
+ TimestampSource =
+ TrackingTimestampSource.Device,
+ IsValidFix = satellites > 0
+ };
+ return true;
+ }
+
+ private static bool TryReadIoElements(
+ ref SequenceReader reader,
+ byte codec,
+ out IReadOnlyDictionary ioValues)
+ {
+ ioValues = null;
+ var extended = codec ==
+ Codec8Extended;
+ if (!TryReadCount(
+ ref reader,
+ extended,
+ out _) ||
+ !TryReadCount(
+ ref reader,
+ extended,
+ out var totalCount) ||
+ totalCount >
+ MaximumIoElementsPerRecord)
+ return false;
+
+ var capturedValues =
+ new Dictionary();
+ var parsedCount = 0;
+ if (!TryReadFixedIoGroup(
+ ref reader,
+ extended,
+ 1,
+ capturedValues,
+ ref parsedCount) ||
+ !TryReadFixedIoGroup(
+ ref reader,
+ extended,
+ 2,
+ capturedValues,
+ ref parsedCount) ||
+ !TryReadFixedIoGroup(
+ ref reader,
+ extended,
+ 4,
+ capturedValues,
+ ref parsedCount) ||
+ !TryReadFixedIoGroup(
+ ref reader,
+ extended,
+ 8,
+ capturedValues,
+ ref parsedCount))
+ return false;
+
+ if (extended)
+ {
+ if (!TryReadUInt16(
+ ref reader,
+ out var variableCount) ||
+ parsedCount + variableCount >
+ MaximumIoElementsPerRecord)
+ return false;
+
+ for (var index = 0;
+ index < variableCount;
+ index++)
+ {
+ if (!TryReadUInt16(
+ ref reader,
+ out _) ||
+ !TryReadUInt16(
+ ref reader,
+ out var valueLength) ||
+ valueLength >
+ MaximumVariableIoValueBytes ||
+ !TryAdvance(
+ ref reader,
+ valueLength))
+ return false;
+ }
+
+ parsedCount += variableCount;
+ }
+
+ if (parsedCount != totalCount)
+ return false;
+
+ ioValues = capturedValues;
+ return true;
+ }
+
+ private static bool TryReadFixedIoGroup(
+ ref SequenceReader reader,
+ bool extended,
+ int valueLength,
+ IDictionary capturedValues,
+ ref int parsedCount)
+ {
+ if (!TryReadCount(
+ ref reader,
+ extended,
+ out var count) ||
+ parsedCount + count >
+ MaximumIoElementsPerRecord)
+ return false;
+
+ for (var index = 0;
+ index < count;
+ index++)
+ {
+ if (!TryReadIoId(
+ ref reader,
+ extended,
+ out var avlId) ||
+ !TryReadUnsigned(
+ ref reader,
+ valueLength,
+ out var rawValue))
+ return false;
+
+ if (TeltonikaIoMapper.IsAllowlisted(
+ avlId,
+ valueLength))
+ capturedValues[avlId] = rawValue;
+ }
+
+ parsedCount += count;
+ return true;
+ }
+
+ private static bool TryReadIoId(
+ ref SequenceReader reader,
+ bool extended,
+ out int avlId)
+ {
+ if (!extended)
+ {
+ if (!reader.TryRead(out var byteId))
+ {
+ avlId = 0;
+ return false;
+ }
+
+ avlId = byteId;
+ return true;
+ }
+
+ if (!TryReadUInt16(
+ ref reader,
+ out var shortId))
+ {
+ avlId = 0;
+ return false;
+ }
+
+ avlId = shortId;
+ return true;
+ }
+
+ private static bool TryReadUnsigned(
+ ref SequenceReader reader,
+ int length,
+ out ulong value)
+ {
+ value = 0;
+ for (var index = 0;
+ index < length;
+ index++)
+ {
+ if (!reader.TryRead(out var next))
+ return false;
+ value = (value << 8) | next;
+ }
+
+ return true;
+ }
+
+ private static bool TryReadCount(
+ ref SequenceReader reader,
+ bool extended,
+ out int count)
+ {
+ count = 0;
+ if (!extended)
+ {
+ if (!reader.TryRead(out var byteCount))
+ return false;
+ count = byteCount;
+ return true;
+ }
+
+ if (!TryReadUInt16(
+ ref reader,
+ out var shortCount))
+ return false;
+ count = shortCount;
+ return true;
+ }
+
+ private static bool TryReadExpectedCrc(
+ ReadOnlySequence input,
+ uint dataFieldLength,
+ out ushort expectedCrc)
+ {
+ var crcReader = new SequenceReader(
+ input.Slice(
+ AvlHeaderLength +
+ dataFieldLength,
+ AvlCrcLength));
+ if (!TryReadUInt32(
+ ref crcReader,
+ out var crcValue) ||
+ crcValue > ushort.MaxValue)
+ {
+ expectedCrc = 0;
+ return false;
+ }
+
+ expectedCrc = (ushort)crcValue;
+ return true;
+ }
+
+ private static bool TryReadUInt16(
+ ref SequenceReader reader,
+ out ushort value)
+ {
+ if (!reader.TryReadBigEndian(
+ out short signedValue))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = unchecked((ushort)signedValue);
+ return true;
+ }
+
+ private static bool TryReadUInt32(
+ ref SequenceReader reader,
+ out uint value)
+ {
+ if (!reader.TryReadBigEndian(
+ out int signedValue))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = unchecked((uint)signedValue);
+ return true;
+ }
+
+ private static bool TryAdvance(
+ ref SequenceReader reader,
+ int length)
+ {
+ if (length < 0 ||
+ reader.Remaining < length)
+ return false;
+
+ reader.Advance(length);
+ return true;
+ }
+
+ private static string CreateEventId(
+ byte codec,
+ ReadOnlySequence record)
+ {
+ using var hash = IncrementalHash.CreateHash(
+ HashAlgorithmName.SHA256);
+ hash.AppendData(new[] { codec });
+ foreach (var segment in record)
+ hash.AppendData(segment.Span);
+ return "teltonika:" +
+ Convert.ToHexString(
+ hash.GetHashAndReset())
+ .ToLowerInvariant();
+ }
+
+ private static ProtocolParseResult NeedMore(
+ ReadOnlySequence input)
+ {
+ return new ProtocolParseResult
+ {
+ Status =
+ ProtocolParseStatus.NeedMoreData,
+ Consumed = input.Start,
+ Examined = input.End
+ };
+ }
+
+ private static ProtocolParseResult Terminal(
+ ReadOnlySequence input,
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ string reasonCode)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ ReasonCode = reasonCode
+ };
+ }
+ }
+
+ internal sealed class TeltonikaAvlDataParseResult
+ {
+ public ProtocolParseStatus Status { get; set; }
+ public IReadOnlyCollection
+ Positions { get; set; } =
+ Array.Empty();
+ public byte RecordCount { get; set; }
+ public TeltonikaProtocolData ProtocolData { get; set; }
+ public string ReasonCode { get; set; }
+
+ public static TeltonikaAvlDataParseResult Failure(
+ ProtocolParseStatus status,
+ string reasonCode)
+ {
+ return new TeltonikaAvlDataParseResult
+ {
+ Status = status,
+ ReasonCode = reasonCode
+ };
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs
new file mode 100644
index 000000000..b77293b4b
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCodec8UdpProtocolSession.cs
@@ -0,0 +1,259 @@
+using System;
+using System.Buffers;
+using System.Buffers.Binary;
+using System.Text;
+using Resgrid.Model;
+
+namespace Resgrid.Providers.Tracking.Protocols.Teltonika
+{
+ public sealed class TeltonikaCodec8UdpProtocolSession :
+ ITrackingProtocolSession,
+ ITrackingProtocolPositionEnricher
+ {
+ private const int ImeiLength = 15;
+ private const int DatagramLengthFieldBytes = 2;
+ private const int MinimumDataFieldLength = 3;
+ private const int MinimumPacketLength =
+ 2 +
+ 1 +
+ 1 +
+ 2 +
+ ImeiLength +
+ MinimumDataFieldLength;
+ private const byte ChannelMarker = 0x01;
+ private const ushort ResponsePacketLength = 5;
+
+ private readonly int _maximumFrameBytes;
+
+ public TeltonikaCodec8UdpProtocolSession(
+ TrackingSessionContext context)
+ {
+ if (context == null)
+ throw new ArgumentNullException(nameof(context));
+ if (context.MaxFrameBytes <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(context.MaxFrameBytes));
+
+ _maximumFrameBytes = context.MaxFrameBytes;
+ }
+
+ public ProtocolParseResult Parse(
+ ref ReadOnlySequence input)
+ {
+ if (input.Length <
+ DatagramLengthFieldBytes)
+ return NeedMore(input);
+
+ var reader = new SequenceReader(input);
+ if (!TryReadUInt16(
+ ref reader,
+ out var packetLength))
+ return NeedMore(input);
+
+ var frameLength =
+ (long)DatagramLengthFieldBytes +
+ packetLength;
+ if (frameLength > _maximumFrameBytes)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "frame-too-large");
+ }
+ if (input.Length < frameLength)
+ return NeedMore(input);
+ if (input.Length != frameLength)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "datagram-length-mismatch");
+ }
+ if (packetLength < MinimumPacketLength)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "data-length-invalid");
+ }
+
+ if (!TryReadUInt16(
+ ref reader,
+ out var channelPacketId) ||
+ !reader.TryRead(out var channelMarker) ||
+ channelMarker != ChannelMarker ||
+ !reader.TryRead(out var avlPacketId) ||
+ !TryReadUInt16(
+ ref reader,
+ out var imeiLength))
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "udp-header-invalid");
+ }
+ if (imeiLength != ImeiLength ||
+ reader.Remaining <
+ imeiLength +
+ MinimumDataFieldLength)
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "imei-length-invalid");
+ }
+
+ var imeiBytes = input
+ .Slice(
+ reader.Position,
+ imeiLength)
+ .ToArray();
+ for (var index = 0;
+ index < imeiBytes.Length;
+ index++)
+ {
+ if (imeiBytes[index] < (byte)'0' ||
+ imeiBytes[index] > (byte)'9')
+ {
+ return Terminal(
+ input.End,
+ ProtocolParseStatus.Malformed,
+ "imei-invalid");
+ }
+ }
+
+ reader.Advance(imeiLength);
+ var avlData =
+ TeltonikaCodec8ProtocolSession
+ .ParseDataField(
+ input.Slice(
+ reader.Position));
+ if (avlData.Status !=
+ ProtocolParseStatus.Positions)
+ {
+ return Terminal(
+ input.End,
+ avlData.Status,
+ avlData.ReasonCode);
+ }
+
+ var acknowledgementToken = new byte[3];
+ BinaryPrimitives.WriteUInt16BigEndian(
+ acknowledgementToken,
+ channelPacketId);
+ acknowledgementToken[2] = avlPacketId;
+ return new ProtocolParseResult
+ {
+ Status = ProtocolParseStatus.Positions,
+ Consumed = input.End,
+ Examined = input.End,
+ Message = new ProtocolMessage
+ {
+ MessageType =
+ ProtocolMessageType.Positions,
+ ExternalIdentifier =
+ Encoding.ASCII.GetString(
+ imeiBytes),
+ Positions = avlData.Positions,
+ ProtocolData = avlData.ProtocolData,
+ AcknowledgementToken =
+ acknowledgementToken,
+ RequiresResponse = true
+ }
+ };
+ }
+
+ public void EnrichPositions(
+ ProtocolMessage message,
+ UnitTrackingDevice device)
+ {
+ TeltonikaIoMapper.EnrichPositions(
+ message,
+ device);
+ }
+
+ public ReadOnlyMemory BuildResponse(
+ ProtocolMessage message,
+ TrackingAcceptance acceptance)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (acceptance == null)
+ throw new ArgumentNullException(nameof(acceptance));
+ if (message.MessageType !=
+ ProtocolMessageType.Positions ||
+ message.AcknowledgementToken.Length != 3)
+ return ReadOnlyMemory.Empty;
+
+ byte acceptedRecords = 0;
+ if (acceptance.Status ==
+ TrackingAcceptanceStatus.Accepted &&
+ message.Positions != null &&
+ acceptance.AcceptedPositions ==
+ message.Positions.Count &&
+ acceptance.AcceptedPositions <=
+ byte.MaxValue)
+ {
+ acceptedRecords =
+ (byte)acceptance.AcceptedPositions;
+ }
+
+ var response = new byte[
+ DatagramLengthFieldBytes +
+ ResponsePacketLength];
+ BinaryPrimitives.WriteUInt16BigEndian(
+ response,
+ ResponsePacketLength);
+ message.AcknowledgementToken.Span
+ .Slice(0, 2)
+ .CopyTo(
+ response.AsSpan(2, 2));
+ response[4] = ChannelMarker;
+ response[5] =
+ message.AcknowledgementToken.Span[2];
+ response[6] = acceptedRecords;
+ return response;
+ }
+
+ private static bool TryReadUInt16(
+ ref SequenceReader reader,
+ out ushort value)
+ {
+ if (!reader.TryReadBigEndian(
+ out short signedValue))
+ {
+ value = 0;
+ return false;
+ }
+
+ value = unchecked((ushort)signedValue);
+ return true;
+ }
+
+ private static ProtocolParseResult NeedMore(
+ ReadOnlySequence input)
+ {
+ return new ProtocolParseResult
+ {
+ Status =
+ ProtocolParseStatus.NeedMoreData,
+ Consumed = input.Start,
+ Examined = input.End
+ };
+ }
+
+ private static ProtocolParseResult Terminal(
+ SequencePosition consumed,
+ ProtocolParseStatus status,
+ string reasonCode)
+ {
+ return new ProtocolParseResult
+ {
+ Status = status,
+ Consumed = consumed,
+ Examined = consumed,
+ ReasonCode = reasonCode
+ };
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs
new file mode 100644
index 000000000..1789e530a
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaCrc16.cs
@@ -0,0 +1,33 @@
+using System.Buffers;
+
+namespace Resgrid.Providers.Tracking.Protocols.Teltonika
+{
+ internal static class TeltonikaCrc16
+ {
+ private const ushort Polynomial = 0xA001;
+
+ public static ushort Compute(
+ ReadOnlySequence data)
+ {
+ ushort crc = 0;
+ foreach (var segment in data)
+ {
+ foreach (var value in segment.Span)
+ {
+ crc ^= value;
+ for (var bit = 0;
+ bit < 8;
+ bit++)
+ {
+ crc = (crc & 1) != 0
+ ? (ushort)((crc >> 1) ^
+ Polynomial)
+ : (ushort)(crc >> 1);
+ }
+ }
+ }
+
+ return crc;
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs
new file mode 100644
index 000000000..d70043095
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/Teltonika/TeltonikaIoMapper.cs
@@ -0,0 +1,163 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols.Teltonika
+{
+ internal static class TeltonikaIoMapper
+ {
+ private const string ProtocolKey =
+ "teltonika-codec8";
+
+ private static readonly Lazy<
+ HashSet<(int AvlId, int ValueBytes)>>
+ Allowlist = new(CreateAllowlist);
+
+ public static bool IsAllowlisted(
+ int avlId,
+ int valueBytes)
+ {
+ return Allowlist.Value.Contains(
+ (avlId, valueBytes));
+ }
+
+ public static void EnrichPositions(
+ ProtocolMessage message,
+ UnitTrackingDevice device)
+ {
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (device == null)
+ throw new ArgumentNullException(nameof(device));
+ if (message.MessageType !=
+ ProtocolMessageType.Positions ||
+ message.ProtocolData is not
+ TeltonikaProtocolData protocolData)
+ return;
+
+ var profile = UnitTrackingCatalog.GetProfile(
+ device.ModelKey);
+ if (profile == null ||
+ !string.Equals(
+ profile.ProtocolKey,
+ ProtocolKey,
+ StringComparison.OrdinalIgnoreCase) ||
+ !string.Equals(
+ device.ProtocolKey,
+ ProtocolKey,
+ StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var ioMap = UnitTrackingCatalog.GetIoMap(
+ profile.IoMapKey);
+ if (ioMap == null ||
+ !string.Equals(
+ ioMap.ProtocolKey,
+ ProtocolKey,
+ StringComparison.OrdinalIgnoreCase))
+ return;
+
+ var positions =
+ message.Positions?.ToList() ??
+ new List();
+ if (positions.Count != protocolData.Records.Count)
+ {
+ throw new InvalidOperationException(
+ "Teltonika I/O metadata does not match the decoded position count.");
+ }
+
+ for (var index = 0;
+ index < positions.Count;
+ index++)
+ {
+ foreach (var mapping in ioMap.Mappings)
+ {
+ if (protocolData.Records[index]
+ .TryGetValue(
+ mapping.AvlId,
+ out var rawValue))
+ {
+ Apply(
+ positions[index],
+ mapping,
+ rawValue);
+ }
+ }
+ }
+ }
+
+ private static HashSet<(int AvlId, int ValueBytes)>
+ CreateAllowlist()
+ {
+ return UnitTrackingCatalog.IoMaps
+ .Where(ioMap => string.Equals(
+ ioMap.ProtocolKey,
+ ProtocolKey,
+ StringComparison.OrdinalIgnoreCase))
+ .SelectMany(ioMap => ioMap.Mappings)
+ .Select(mapping =>
+ (mapping.AvlId, mapping.ValueBytes))
+ .ToHashSet();
+ }
+
+ private static void Apply(
+ CanonicalTrackingPosition position,
+ UnitTrackingIoMapping mapping,
+ ulong rawValue)
+ {
+ if ((mapping.MinimumRawValue.HasValue &&
+ rawValue < mapping.MinimumRawValue.Value) ||
+ (mapping.MaximumRawValue.HasValue &&
+ rawValue > mapping.MaximumRawValue.Value))
+ return;
+
+ var value = rawValue * mapping.Multiplier;
+ switch (mapping.Target)
+ {
+ case UnitTrackingIoTarget.Hdop:
+ position.Hdop = value;
+ break;
+ case UnitTrackingIoTarget.BatteryPercent:
+ if (value >= 0m && value <= 100m)
+ position.BatteryPercent = value;
+ break;
+ case UnitTrackingIoTarget.ExternalPowerVolts:
+ if (value >= 0m)
+ position.ExternalPowerVolts = value;
+ break;
+ case UnitTrackingIoTarget.SignalPercent:
+ if (value >= 0m &&
+ value <= 100m &&
+ decimal.Truncate(value) == value)
+ position.SignalPercent = (int)value;
+ break;
+ case UnitTrackingIoTarget.Ignition:
+ if (rawValue <= 1)
+ position.Ignition =
+ rawValue == 1;
+ break;
+ case UnitTrackingIoTarget.IsMoving:
+ if (rawValue <= 1)
+ position.IsMoving =
+ rawValue == 1;
+ break;
+ }
+ }
+ }
+
+ internal sealed class TeltonikaProtocolData
+ {
+ public TeltonikaProtocolData(
+ IReadOnlyList>
+ records)
+ {
+ Records = records ??
+ throw new ArgumentNullException(nameof(records));
+ }
+
+ public IReadOnlyList>
+ Records { get; }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs
new file mode 100644
index 000000000..27113e0f2
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolCatalogValidator.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Linq;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols
+{
+ public static class TrackingProtocolCatalogValidator
+ {
+ public static void Validate(
+ ITrackingProtocolModuleRegistry moduleRegistry)
+ {
+ if (moduleRegistry == null)
+ throw new ArgumentNullException(
+ nameof(moduleRegistry));
+
+ var requirements = UnitTrackingCatalog.Profiles
+ .Where(profile =>
+ profile.TransportType ==
+ UnitTrackingTransportType.NativeTcpUdp)
+ .SelectMany(profile =>
+ profile.SupportedTransports.Select(
+ transport => new
+ {
+ profile.ProtocolKey,
+ Transport = ParseTransport(
+ profile.Key,
+ transport)
+ }))
+ .Distinct()
+ .ToList();
+
+ foreach (var requirement in requirements)
+ {
+ if (!moduleRegistry.TryResolve(
+ requirement.ProtocolKey,
+ requirement.Transport,
+ out _))
+ {
+ throw new InvalidOperationException(
+ $"The unit tracking catalog references unregistered protocol '{requirement.ProtocolKey}' over {requirement.Transport}.");
+ }
+ }
+ }
+
+ private static TrackingSocketTransport ParseTransport(
+ string profileKey,
+ string transport)
+ {
+ if (string.Equals(
+ transport,
+ "Tcp",
+ StringComparison.Ordinal))
+ return TrackingSocketTransport.Tcp;
+ if (string.Equals(
+ transport,
+ "Udp",
+ StringComparison.Ordinal))
+ return TrackingSocketTransport.Udp;
+
+ throw new InvalidOperationException(
+ $"Native unit tracking profile '{profileKey}' has an invalid socket transport.");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs
new file mode 100644
index 000000000..d31c7dd14
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolContract.cs
@@ -0,0 +1,107 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Net;
+using System.Threading;
+using Resgrid.Model;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Providers.Tracking.Protocols
+{
+ public static class TrackingProtocolContract
+ {
+ public const int Version = 1;
+ }
+
+ public enum TrackingSocketTransport
+ {
+ Unknown = 0,
+ Tcp = 1,
+ Udp = 2
+ }
+
+ public enum ProtocolParseStatus
+ {
+ NeedMoreData = 0,
+ Login = 1,
+ Heartbeat = 2,
+ Positions = 3,
+ Malformed = 4,
+ Unsupported = 5,
+ CloseSession = 6
+ }
+
+ public enum ProtocolMessageType
+ {
+ Unknown = 0,
+ Login = 1,
+ Heartbeat = 2,
+ Positions = 3
+ }
+
+ public enum TrackingAcceptanceStatus
+ {
+ Accepted = 0,
+ Rejected = 1,
+ Unavailable = 2
+ }
+
+ public sealed class TrackingSessionContext
+ {
+ public string SessionId { get; set; }
+ public TrackingSocketTransport Transport { get; set; }
+ public EndPoint RemoteEndPoint { get; set; }
+ public DateTime ConnectedOnUtc { get; set; }
+ public int MaxFrameBytes { get; set; }
+ public CancellationToken CancellationToken { get; set; }
+ }
+
+ public sealed class ProtocolMessage
+ {
+ public ProtocolMessageType MessageType { get; set; }
+ public string ExternalIdentifier { get; set; }
+ public IReadOnlyCollection Positions { get; set; } =
+ Array.Empty();
+ public ReadOnlyMemory AcknowledgementToken { get; set; }
+ public bool RequiresResponse { get; set; }
+ public object ProtocolData { get; set; }
+ }
+
+ public sealed class ProtocolParseResult
+ {
+ public ProtocolParseStatus Status { get; set; }
+ public SequencePosition Consumed { get; set; }
+ public SequencePosition Examined { get; set; }
+ public ProtocolMessage Message { get; set; }
+ public string ReasonCode { get; set; }
+ }
+
+ public sealed class TrackingAcceptance
+ {
+ public TrackingAcceptanceStatus Status { get; set; }
+ public int AcceptedPositions { get; set; }
+ public string ReasonCode { get; set; }
+ }
+
+ public interface ITrackingProtocolModule
+ {
+ string ProtocolKey { get; }
+ IReadOnlySet SupportedTransports { get; }
+ ITrackingProtocolSession CreateSession(TrackingSessionContext context);
+ }
+
+ public interface ITrackingProtocolSession
+ {
+ ProtocolParseResult Parse(ref ReadOnlySequence input);
+ ReadOnlyMemory BuildResponse(
+ ProtocolMessage message,
+ TrackingAcceptance acceptance);
+ }
+
+ public interface ITrackingProtocolPositionEnricher
+ {
+ void EnrichPositions(
+ ProtocolMessage message,
+ UnitTrackingDevice device);
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs
new file mode 100644
index 000000000..6409645ee
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolKeys.cs
@@ -0,0 +1,9 @@
+namespace Resgrid.Providers.Tracking.Protocols
+{
+ public static class TrackingProtocolKeys
+ {
+ public const string Queclink = "queclink-attrack";
+ public const string Gt06 = "gt06";
+ public const string Teltonika = "teltonika-codec8";
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs
new file mode 100644
index 000000000..4669e5b4d
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Protocols/TrackingProtocolModuleRegistry.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Resgrid.Providers.Tracking.Protocols
+{
+ public class TrackingProtocolModuleRegistry : ITrackingProtocolModuleRegistry
+ {
+ private readonly Dictionary _modulesByKey;
+
+ public TrackingProtocolModuleRegistry(IEnumerable modules)
+ {
+ var registeredModules = (modules ?? Enumerable.Empty())
+ .Where(module => module != null)
+ .ToList();
+ _modulesByKey = new Dictionary(
+ StringComparer.OrdinalIgnoreCase);
+
+ foreach (var module in registeredModules)
+ {
+ if (string.IsNullOrWhiteSpace(module.ProtocolKey))
+ throw new InvalidOperationException("Tracking protocol modules must declare a protocol key.");
+ if (module.SupportedTransports == null || module.SupportedTransports.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Tracking protocol module '{module.ProtocolKey}' must declare at least one transport.");
+ }
+ if (module.SupportedTransports.Any(
+ transport => transport != TrackingSocketTransport.Tcp &&
+ transport != TrackingSocketTransport.Udp))
+ {
+ throw new InvalidOperationException(
+ $"Tracking protocol module '{module.ProtocolKey}' declares an unsupported transport.");
+ }
+
+ var protocolKey = module.ProtocolKey.Trim();
+ if (!_modulesByKey.TryAdd(protocolKey, module))
+ {
+ throw new InvalidOperationException(
+ $"Tracking protocol module '{protocolKey}' is registered more than once.");
+ }
+ }
+
+ Modules = registeredModules.AsReadOnly();
+ }
+
+ public IReadOnlyCollection Modules { get; }
+
+ public bool TryResolve(
+ string protocolKey,
+ TrackingSocketTransport transport,
+ out ITrackingProtocolModule module)
+ {
+ module = null;
+ if (string.IsNullOrWhiteSpace(protocolKey) ||
+ transport == TrackingSocketTransport.Unknown ||
+ !_modulesByKey.TryGetValue(protocolKey.Trim(), out var registeredModule) ||
+ !registeredModule.SupportedTransports.Contains(transport))
+ return false;
+
+ module = registeredModule;
+ return true;
+ }
+
+ public ITrackingProtocolModule Resolve(
+ string protocolKey,
+ TrackingSocketTransport transport)
+ {
+ if (TryResolve(protocolKey, transport, out var module))
+ return module;
+
+ throw new KeyNotFoundException(
+ $"No tracking protocol module is registered for '{protocolKey}' over {transport}.");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj b/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj
new file mode 100644
index 000000000..eb3045298
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/Resgrid.Providers.Tracking.csproj
@@ -0,0 +1,17 @@
+
+
+ net9.0
+ Resgrid.Providers.Tracking
+ Resgrid.Providers.Tracking
+ Debug;Release;Docker
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs b/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs
new file mode 100644
index 000000000..0182ee5d0
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/TrackingProviderModule.cs
@@ -0,0 +1,27 @@
+using Autofac;
+using Resgrid.Providers.Tracking.Protocols;
+using Resgrid.Providers.Tracking.Protocols.Gt06;
+using Resgrid.Providers.Tracking.Protocols.Queclink;
+using Resgrid.Providers.Tracking.Protocols.Teltonika;
+
+namespace Resgrid.Providers.Tracking
+{
+ public class TrackingProviderModule : Module
+ {
+ protected override void Load(ContainerBuilder builder)
+ {
+ builder.RegisterType()
+ .As()
+ .SingleInstance();
+ builder.RegisterType()
+ .As()
+ .SingleInstance();
+ builder.RegisterType()
+ .As()
+ .SingleInstance();
+ builder.RegisterType()
+ .As()
+ .SingleInstance();
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs
index a88748b06..dac07c3bc 100644
--- a/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs
+++ b/Repositories/Resgrid.Repositories.NoSqlRepository/NoSqlDataModule.cs
@@ -15,6 +15,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
}
}
}
diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs
new file mode 100644
index 000000000..4a14acf4b
--- /dev/null
+++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationRetentionRepository.cs
@@ -0,0 +1,63 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Model.Repositories;
+
+namespace Resgrid.Repositories.NoSqlRepository
+{
+ public sealed class UnitLocationRetentionRepository :
+ IUnitLocationRetentionRepository
+ {
+ private readonly Lazy
+ _postgresRepository;
+ private readonly Lazy
+ _mongoRepository;
+
+ public UnitLocationRetentionRepository(
+ Lazy postgresRepository,
+ Lazy mongoRepository)
+ {
+ _postgresRepository =
+ postgresRepository ??
+ throw new ArgumentNullException(
+ nameof(postgresRepository));
+ _mongoRepository =
+ mongoRepository ??
+ throw new ArgumentNullException(
+ nameof(mongoRepository));
+ }
+
+ public Task DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default)
+ {
+ if (DataConfig.DocDatabaseType ==
+ DatabaseTypes.Postgres)
+ {
+ return _postgresRepository.Value
+ .DeleteHardwareLocationsBeforeAsync(
+ departmentId,
+ cutoffUtc,
+ batchSize,
+ cancellationToken);
+ }
+
+ if (DataConfig.DocDatabaseType ==
+ DatabaseTypes.MongoDb)
+ {
+ return _mongoRepository.Value
+ .DeleteHardwareLocationsBeforeAsync(
+ departmentId,
+ cutoffUtc,
+ batchSize,
+ cancellationToken);
+ }
+
+ throw new InvalidOperationException(
+ $"Document database type '{DataConfig.DocDatabaseType}' does not support unit-location retention.");
+ }
+ }
+}
diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs
index 7cc4dc80c..42613feca 100644
--- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs
+++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsDocRepository.cs
@@ -5,6 +5,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Resgrid.Model.Repositories;
@@ -192,6 +193,58 @@ public async Task UpdateAsync(UnitsLocation location)
}
}
+ public async Task
+ DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default)
+ {
+ if (departmentId <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(departmentId));
+ if (cutoffUtc.Kind != DateTimeKind.Utc)
+ {
+ throw new ArgumentException(
+ "The retention cutoff must be UTC.",
+ nameof(cutoffUtc));
+ }
+ if (batchSize <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(batchSize));
+
+ using var connection = new NpgsqlConnection(
+ Config.DataConfig.DocumentConnectionString);
+ await connection.OpenAsync(cancellationToken);
+ var command = new Dapper.CommandDefinition(
+ @"WITH expired AS
+ (
+ SELECT id
+ FROM public.unitlocations
+ WHERE departmentid = @departmentId
+ AND sourcetype = @sourceType
+ AND ""timestamp"" < @cutoff
+ ORDER BY ""timestamp"", id
+ LIMIT @batchSize
+ FOR UPDATE SKIP LOCKED
+ )
+ DELETE FROM public.unitlocations AS locations
+ USING expired
+ WHERE locations.id = expired.id;",
+ new
+ {
+ departmentId,
+ sourceType =
+ (int)UnitLocationSourceType
+ .HardwareTracker,
+ cutoff =
+ ToPostgresTimestamp(cutoffUtc),
+ batchSize
+ },
+ cancellationToken: cancellationToken);
+ return await connection.ExecuteAsync(command);
+ }
+
private static DateTime ToPostgresTimestamp(DateTime value)
{
return DateTime.SpecifyKind(value, DateTimeKind.Unspecified);
diff --git a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs
index 36ccd9eac..1b67b1962 100644
--- a/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs
+++ b/Repositories/Resgrid.Repositories.NoSqlRepository/UnitLocationsMongoRepository.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using Resgrid.Config;
@@ -54,6 +55,58 @@ public async Task UpdateAsync(UnitsLocation location)
return UnitLocationWriteResult.Inserted(location);
}
+ public async Task
+ DeleteHardwareLocationsBeforeAsync(
+ int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken = default)
+ {
+ if (departmentId <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(departmentId));
+ if (cutoffUtc.Kind != DateTimeKind.Utc)
+ {
+ throw new ArgumentException(
+ "The retention cutoff must be UTC.",
+ nameof(cutoffUtc));
+ }
+ if (batchSize <= 0)
+ throw new ArgumentOutOfRangeException(
+ nameof(batchSize));
+
+ await EnsureIndexesAsync().WaitAsync(
+ cancellationToken);
+ var filter =
+ Builders.Filter.And(
+ Builders.Filter.Eq(
+ location => location.DepartmentId,
+ departmentId),
+ Builders.Filter.Eq(
+ location => location.SourceType,
+ (int)UnitLocationSourceType
+ .HardwareTracker),
+ Builders.Filter.Lt(
+ location => location.Timestamp,
+ cutoffUtc));
+ var ids = await _collection
+ .Find(filter)
+ .SortBy(location => location.Timestamp)
+ .ThenBy(location => location.Id)
+ .Limit(batchSize)
+ .Project(location => location.Id)
+ .ToListAsync(cancellationToken);
+ if (ids.Count == 0)
+ return 0;
+
+ var result = await _collection.DeleteManyAsync(
+ Builders.Filter.In(
+ location => location.Id,
+ ids),
+ cancellationToken);
+ return checked((int)result.DeletedCount);
+ }
+
public Task EnsureIndexesAsync()
{
lock (_indexLock)
diff --git a/Resgrid.sln b/Resgrid.sln
index 390f6643b..47e72b976 100644
--- a/Resgrid.sln
+++ b/Resgrid.sln
@@ -108,6 +108,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Resgrid.Providers.Chatbot",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Quidjibo.SqlServer", "Workers\Support\Quidjibo.SqlServer\Quidjibo.SqlServer.csproj", "{93931385-3360-455F-B051-CF7B56C0ED17}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Providers.Tracking", "Providers\Resgrid.Providers.Tracking\Resgrid.Providers.Tracking.csproj", "{97858451-3ECB-4E11-943B-EEDAFC7F1677}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.TrackerGateway", "Workers\Resgrid.TrackerGateway\Resgrid.TrackerGateway.csproj", "{F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Resgrid.Tracking.Tests", "Tests\Resgrid.Tracking.Tests\Resgrid.Tracking.Tests.csproj", "{BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Azure|Any CPU = Azure|Any CPU
@@ -1534,6 +1540,114 @@ Global
{93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x86.Build.0 = Debug|Any CPU
{93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x64.ActiveCfg = Debug|Any CPU
{93931385-3360-455F-B051-CF7B56C0ED17}.Staging|x64.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|Any CPU.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|Any CPU.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x86.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x86.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x64.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Azure|x64.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|Any CPU.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x86.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x86.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x64.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Cloud|x64.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x86.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Debug|x64.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|Any CPU.ActiveCfg = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|Any CPU.Build.0 = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x86.ActiveCfg = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x86.Build.0 = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x64.ActiveCfg = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Docker|x64.Build.0 = Docker|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|Any CPU.Build.0 = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x86.ActiveCfg = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x86.Build.0 = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x64.ActiveCfg = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Release|x64.Build.0 = Release|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|Any CPU.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|Any CPU.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x86.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x86.Build.0 = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x64.ActiveCfg = Debug|Any CPU
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677}.Staging|x64.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|Any CPU.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x86.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x86.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x64.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Azure|x64.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|Any CPU.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x86.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x86.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x64.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Cloud|x64.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x86.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Debug|x64.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|Any CPU.ActiveCfg = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|Any CPU.Build.0 = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x86.ActiveCfg = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x86.Build.0 = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x64.ActiveCfg = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Docker|x64.Build.0 = Docker|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x86.ActiveCfg = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x86.Build.0 = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x64.ActiveCfg = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Release|x64.Build.0 = Release|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|Any CPU.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|Any CPU.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x86.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x86.Build.0 = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x64.ActiveCfg = Debug|Any CPU
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6}.Staging|x64.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|Any CPU.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|Any CPU.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x86.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x86.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x64.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Azure|x64.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|Any CPU.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|Any CPU.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x86.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x86.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x64.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Cloud|x64.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x86.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Debug|x64.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|Any CPU.ActiveCfg = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|Any CPU.Build.0 = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x86.ActiveCfg = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x86.Build.0 = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x64.ActiveCfg = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Docker|x64.Build.0 = Docker|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x86.ActiveCfg = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x86.Build.0 = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x64.ActiveCfg = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Release|x64.Build.0 = Release|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|Any CPU.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|Any CPU.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x86.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x86.Build.0 = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x64.ActiveCfg = Debug|Any CPU
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE}.Staging|x64.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1579,6 +1693,9 @@ Global
{C2D3E4F5-A6B7-8901-BCDE-F12345678901} = {D43D1D6B-66A9-4A57-9EA3-8DECC92FA583}
{D3E4F5A6-B7C8-9012-CDEF-012345678902} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD}
{93931385-3360-455F-B051-CF7B56C0ED17} = {89331D76-C527-479D-8F30-8033A04C625F}
+ {97858451-3ECB-4E11-943B-EEDAFC7F1677} = {F06D475C-635C-4DE4-82BA-C49A90BA8FCD}
+ {F2E118D5-4B30-4B9B-9D6A-4C81F811F1A6} = {DBB9862A-C008-4C3F-A9DB-320429E4A07F}
+ {BDDE82A7-E221-43E0-9CE2-0AE11F0DE8CE} = {D2D96CD8-CD7D-414D-8B33-A6C363B40C8D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {156116FF-243E-45E8-8717-DB72E95F56AF}
diff --git a/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs
new file mode 100644
index 000000000..f84171e4d
--- /dev/null
+++ b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionRepositoryTests.cs
@@ -0,0 +1,129 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Model.Repositories;
+using Resgrid.Repositories.NoSqlRepository;
+
+namespace Resgrid.Tests.Repositories
+{
+ [TestFixture]
+ [NonParallelizable]
+ public class UnitLocationRetentionRepositoryTests
+ {
+ private DatabaseTypes _originalDocumentDatabaseType;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalDocumentDatabaseType =
+ DataConfig.DocDatabaseType;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ DataConfig.DocDatabaseType =
+ _originalDocumentDatabaseType;
+ }
+
+ [Test]
+ public async Task DeleteHardwareLocationsBeforeAsync_WithPostgres_RoutesOnlyToPostgres()
+ {
+ // Arrange
+ DataConfig.DocDatabaseType =
+ DatabaseTypes.Postgres;
+ var cutoffUtc = new DateTime(
+ 2026,
+ 6,
+ 1,
+ 0,
+ 0,
+ 0,
+ DateTimeKind.Utc);
+ var postgres =
+ new Mock();
+ postgres
+ .Setup(repository =>
+ repository
+ .DeleteHardwareLocationsBeforeAsync(
+ 7,
+ cutoffUtc,
+ 100,
+ It.IsAny()))
+ .ReturnsAsync(12);
+ var mongo =
+ new Mock(
+ MockBehavior.Strict);
+ var repository =
+ new UnitLocationRetentionRepository(
+ new Lazy(
+ () => postgres.Object),
+ new Lazy(
+ () => mongo.Object));
+
+ // Act
+ var deleted =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ 7,
+ cutoffUtc,
+ 100);
+
+ // Assert
+ deleted.Should().Be(12);
+ mongo.VerifyNoOtherCalls();
+ }
+
+ [Test]
+ public async Task DeleteHardwareLocationsBeforeAsync_WithMongo_RoutesOnlyToMongo()
+ {
+ // Arrange
+ DataConfig.DocDatabaseType =
+ DatabaseTypes.MongoDb;
+ var cutoffUtc = new DateTime(
+ 2026,
+ 6,
+ 1,
+ 0,
+ 0,
+ 0,
+ DateTimeKind.Utc);
+ var postgres =
+ new Mock(
+ MockBehavior.Strict);
+ var mongo =
+ new Mock();
+ mongo
+ .Setup(repository =>
+ repository
+ .DeleteHardwareLocationsBeforeAsync(
+ 7,
+ cutoffUtc,
+ 100,
+ It.IsAny()))
+ .ReturnsAsync(9);
+ var repository =
+ new UnitLocationRetentionRepository(
+ new Lazy(
+ () => postgres.Object),
+ new Lazy(
+ () => mongo.Object));
+
+ // Act
+ var deleted =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ 7,
+ cutoffUtc,
+ 100);
+
+ // Assert
+ deleted.Should().Be(9);
+ postgres.VerifyNoOtherCalls();
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs
new file mode 100644
index 000000000..377ff38fa
--- /dev/null
+++ b/Tests/Resgrid.Tests/Repositories/UnitLocationRetentionStoreIntegrationTests.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using FluentAssertions;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using Npgsql;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Repositories.NoSqlRepository;
+
+namespace Resgrid.Tests.Repositories
+{
+ [TestFixture]
+ [NonParallelizable]
+ public class UnitLocationRetentionStoreIntegrationTests
+ {
+ private const string PostgresConnectionEnvironmentVariable =
+ "RESGRID_TRACKING_POSTGRES_DOCUMENT_CONNECTION";
+ private const string MongoConnectionEnvironmentVariable =
+ "RESGRID_TRACKING_MONGODB_CONNECTION";
+ private const string MongoDatabaseEnvironmentVariable =
+ "RESGRID_TRACKING_MONGODB_DATABASE";
+
+ private string _originalDocumentConnectionString;
+ private string _originalMongoConnectionString;
+ private string _originalMongoDatabaseName;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalDocumentConnectionString =
+ DataConfig.DocumentConnectionString;
+ _originalMongoConnectionString =
+ DataConfig.NoSqlConnectionString;
+ _originalMongoDatabaseName =
+ DataConfig.NoSqlDatabaseName;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ DataConfig.DocumentConnectionString =
+ _originalDocumentConnectionString;
+ DataConfig.NoSqlConnectionString =
+ _originalMongoConnectionString;
+ DataConfig.NoSqlDatabaseName =
+ _originalMongoDatabaseName;
+ }
+
+ [Test]
+ [Explicit(
+ "Requires a migrated PostgreSQL document test database.")]
+ [Category("LiveTrackingStore")]
+ public async Task PostgresRetention_LiveStore_DeletesOnlyBoundedExpiredHardwareRows()
+ {
+ var connectionString =
+ RequireEnvironmentVariable(
+ PostgresConnectionEnvironmentVariable);
+ var connectionBuilder =
+ new NpgsqlConnectionStringBuilder(
+ connectionString);
+ RequireTestDatabase(connectionBuilder.Database);
+ DataConfig.DocumentConnectionString =
+ connectionString;
+
+ var fixture = CreateFixture();
+ var repository =
+ new UnitLocationsDocRepository();
+ try
+ {
+ foreach (var location in fixture.Locations)
+ await repository.InsertAsync(location);
+
+ var deleted =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ fixture.DepartmentId,
+ fixture.CutoffUtc,
+ batchSize: 1);
+ var secondBatch =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ fixture.DepartmentId,
+ fixture.CutoffUtc,
+ batchSize: 1);
+ var remaining =
+ await GetPostgresEventIdsAsync(
+ connectionString,
+ fixture.EventPrefix);
+
+ deleted.Should().Be(1);
+ secondBatch.Should().Be(0);
+ remaining.Should().BeEquivalentTo(
+ fixture.ExpectedRemainingEventIds);
+ }
+ finally
+ {
+ await DeletePostgresFixtureAsync(
+ connectionString,
+ fixture.EventPrefix);
+ }
+ }
+
+ [Test]
+ [Explicit(
+ "Requires a dedicated MongoDB tracking test database.")]
+ [Category("LiveTrackingStore")]
+ public async Task MongoRetention_LiveStore_DeletesOnlyBoundedExpiredHardwareDocuments()
+ {
+ var connectionString =
+ RequireEnvironmentVariable(
+ MongoConnectionEnvironmentVariable);
+ var databaseName =
+ RequireEnvironmentVariable(
+ MongoDatabaseEnvironmentVariable);
+ RequireTestDatabase(databaseName);
+ DataConfig.NoSqlConnectionString =
+ connectionString;
+ DataConfig.NoSqlDatabaseName =
+ databaseName;
+
+ var fixture = CreateFixture();
+ var client = new MongoClient(connectionString);
+ var collection = client
+ .GetDatabase(databaseName)
+ .GetCollection(
+ "unitLocations");
+ var eventFilter = EventPrefixFilter(
+ fixture.EventPrefix);
+ var repository =
+ new UnitLocationsMongoRepository();
+ try
+ {
+ foreach (var location in fixture.Locations)
+ await repository.InsertAsync(location);
+
+ var deleted =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ fixture.DepartmentId,
+ fixture.CutoffUtc,
+ batchSize: 1);
+ var secondBatch =
+ await repository
+ .DeleteHardwareLocationsBeforeAsync(
+ fixture.DepartmentId,
+ fixture.CutoffUtc,
+ batchSize: 1);
+ var remaining = await collection
+ .Find(eventFilter)
+ .Project(location => location.EventId)
+ .ToListAsync();
+
+ deleted.Should().Be(1);
+ secondBatch.Should().Be(0);
+ remaining.Should().BeEquivalentTo(
+ fixture.ExpectedRemainingEventIds);
+ }
+ finally
+ {
+ await collection.DeleteManyAsync(
+ eventFilter);
+ }
+ }
+
+ private static RetentionFixture CreateFixture()
+ {
+ var eventPrefix =
+ "retention-integration-" +
+ Guid.NewGuid().ToString("N");
+ var departmentId = Random.Shared.Next(
+ 1000000,
+ 1500000);
+ var otherDepartmentId =
+ departmentId + 1500000;
+ var unitId = departmentId;
+ var otherUnitId = otherDepartmentId;
+ var now = DateTime.UtcNow;
+ var oldTimestamp = now.AddDays(-60);
+ var currentTimestamp = now.AddDays(-5);
+ var oldHardwareEvent =
+ eventPrefix + "-old-hardware";
+ var oldUnitAppEvent =
+ eventPrefix + "-old-unit-app";
+ var currentHardwareEvent =
+ eventPrefix + "-current-hardware";
+ var otherDepartmentEvent =
+ eventPrefix + "-other-department";
+
+ return new RetentionFixture(
+ eventPrefix,
+ departmentId,
+ now.AddDays(-30),
+ new[]
+ {
+ CreateLocation(
+ oldHardwareEvent,
+ departmentId,
+ unitId,
+ oldTimestamp,
+ UnitLocationSourceType.HardwareTracker),
+ CreateLocation(
+ oldUnitAppEvent,
+ departmentId,
+ unitId,
+ oldTimestamp,
+ UnitLocationSourceType.UnitApp),
+ CreateLocation(
+ currentHardwareEvent,
+ departmentId,
+ unitId,
+ currentTimestamp,
+ UnitLocationSourceType.HardwareTracker),
+ CreateLocation(
+ otherDepartmentEvent,
+ otherDepartmentId,
+ otherUnitId,
+ oldTimestamp,
+ UnitLocationSourceType.HardwareTracker)
+ },
+ new[]
+ {
+ oldUnitAppEvent,
+ currentHardwareEvent,
+ otherDepartmentEvent
+ });
+ }
+
+ private static UnitsLocation CreateLocation(
+ string eventId,
+ int departmentId,
+ int unitId,
+ DateTime timestampUtc,
+ UnitLocationSourceType sourceType)
+ {
+ return new UnitsLocation
+ {
+ EventId = eventId,
+ DepartmentId = departmentId,
+ UnitId = unitId,
+ Timestamp = timestampUtc,
+ ReceivedOn = timestampUtc,
+ SourceType = (int)sourceType,
+ SourceId = eventId,
+ SourcePriority = 100,
+ Latitude = 47.6062m,
+ Longitude = -122.3321m
+ };
+ }
+
+ private static string RequireEnvironmentVariable(
+ string name)
+ {
+ var value =
+ Environment.GetEnvironmentVariable(name);
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ Assert.Fail(
+ $"Environment variable '{name}' is required.");
+ }
+
+ return value;
+ }
+
+ private static void RequireTestDatabase(
+ string databaseName)
+ {
+ if (string.IsNullOrWhiteSpace(databaseName) ||
+ (!databaseName.EndsWith(
+ "_test",
+ StringComparison.OrdinalIgnoreCase) &&
+ !databaseName.EndsWith(
+ "_integration",
+ StringComparison.OrdinalIgnoreCase)))
+ {
+ Assert.Fail(
+ "Live retention tests require a database name ending in '_test' or '_integration'.");
+ }
+ }
+
+ private static FilterDefinition
+ EventPrefixFilter(string eventPrefix)
+ {
+ return Builders.Filter.Regex(
+ location => location.EventId,
+ new BsonRegularExpression(
+ "^" + Regex.Escape(eventPrefix)));
+ }
+
+ private static async Task>
+ GetPostgresEventIdsAsync(
+ string connectionString,
+ string eventPrefix)
+ {
+ var eventIds = new List();
+ await using var connection =
+ new NpgsqlConnection(connectionString);
+ await connection.OpenAsync();
+ await using var command = new NpgsqlCommand(
+ @"SELECT eventid
+ FROM public.unitlocations
+ WHERE eventid LIKE @pattern
+ ORDER BY eventid;",
+ connection);
+ command.Parameters.AddWithValue(
+ "pattern",
+ eventPrefix + "%");
+ await using var reader =
+ await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ eventIds.Add(reader.GetString(0));
+ return eventIds;
+ }
+
+ private static async Task DeletePostgresFixtureAsync(
+ string connectionString,
+ string eventPrefix)
+ {
+ await using var connection =
+ new NpgsqlConnection(connectionString);
+ await connection.OpenAsync();
+ await using var command = new NpgsqlCommand(
+ @"DELETE FROM public.unitlocations
+ WHERE eventid LIKE @pattern;",
+ connection);
+ command.Parameters.AddWithValue(
+ "pattern",
+ eventPrefix + "%");
+ await command.ExecuteNonQueryAsync();
+ }
+
+ private sealed class RetentionFixture
+ {
+ public RetentionFixture(
+ string eventPrefix,
+ int departmentId,
+ DateTime cutoffUtc,
+ IReadOnlyCollection locations,
+ IReadOnlyCollection
+ expectedRemainingEventIds)
+ {
+ EventPrefix = eventPrefix;
+ DepartmentId = departmentId;
+ CutoffUtc = cutoffUtc;
+ Locations = locations;
+ ExpectedRemainingEventIds =
+ expectedRemainingEventIds;
+ }
+
+ public string EventPrefix { get; }
+ public int DepartmentId { get; }
+ public DateTime CutoffUtc { get; }
+ public IReadOnlyCollection
+ Locations
+ { get; }
+ public IReadOnlyCollection
+ ExpectedRemainingEventIds
+ { get; }
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs
index 40b2f8812..604c3175d 100644
--- a/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/UnitTrackingCatalogServiceTests.cs
@@ -1,8 +1,10 @@
+using System;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using NUnit.Framework;
using Resgrid.Model;
+using Resgrid.Model.Tracking;
using Resgrid.Services;
namespace Resgrid.Tests.Services
@@ -21,7 +23,29 @@ public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdap
// Assert
profiles.Select(profile => profile.Key)
- .Should().BeEquivalentTo("generic-https", "traccar-forwarder");
+ .Should().BeEquivalentTo(
+ "generic-https",
+ "traccar-forwarder",
+ "teltonika-fmc920",
+ "teltonika-fmm920",
+ "teltonika-fmc130",
+ "teltonika-fmm130",
+ "teltonika-fmc003",
+ "queclink-gv57mg",
+ "queclink-gv350mg",
+ "queclink-gv500ma",
+ "jimi-vl103m",
+ "jimi-jm-vl03",
+ "teltonika-fmm003",
+ "teltonika-fmc125",
+ "teltonika-fmm125",
+ "teltonika-fmc150",
+ "teltonika-fmm150",
+ "teltonika-fmc230",
+ "teltonika-fmm230",
+ "jimi-jm-vl01",
+ "jimi-jm-vl02",
+ "jimi-jm-vl04");
profiles.Single(profile => profile.Key == "generic-https")
.CertificationStatus.Should().Be(UnitTrackingCertificationStatus.Certified);
profiles.Single(profile => profile.Key == "traccar-forwarder")
@@ -35,8 +59,179 @@ public async Task GetProfilesAsync_FoundationProfiles_ExposeCertificationAndAdap
profiles.Single(profile => profile.Key == "traccar-forwarder")
.SetupSummary.Should().Contain("v6.14.5");
profiles.Should().OnlyContain(profile =>
- !string.IsNullOrWhiteSpace(profile.PayloadAdapterKey) &&
- profile.SupportedAuthModes.Count > 0);
+ !string.IsNullOrWhiteSpace(profile.ProtocolKey) &&
+ !string.IsNullOrWhiteSpace(profile.DecoderVariant) &&
+ !string.IsNullOrWhiteSpace(profile.ProtocolDocumentVersion) &&
+ profile.SupportedTransports.Count > 0);
+ profiles.Select(profile => profile.Key)
+ .Should().OnlyHaveUniqueItems();
+ }
+
+ [Test]
+ public async Task GetProfilesAsync_TeltonikaWaveOne_RemainsCandidateWithDataDrivenIoMap()
+ {
+ // Arrange
+ var service = new UnitTrackingCatalogService();
+ var waveOneKeys = new[]
+ {
+ "teltonika-fmc920",
+ "teltonika-fmm920",
+ "teltonika-fmc130",
+ "teltonika-fmm130",
+ "teltonika-fmc003"
+ };
+
+ // Act
+ var profiles = await service.GetProfilesAsync();
+ var teltonika = profiles
+ .Where(profile => waveOneKeys.Contains(
+ profile.Key,
+ StringComparer.Ordinal))
+ .ToList();
+
+ // Assert
+ teltonika.Should().HaveCount(5);
+ teltonika.Select(profile => profile.Model)
+ .Should().BeEquivalentTo(
+ "FMC920",
+ "FMM920",
+ "FMC130",
+ "FMM130",
+ "FMC003");
+ teltonika.Should().OnlyContain(profile =>
+ profile.TransportType ==
+ UnitTrackingTransportType.NativeTcpUdp &&
+ profile.ProtocolKey == "teltonika-codec8" &&
+ profile.DecoderVariant == "codec8-or-8e" &&
+ profile.IoMapKey ==
+ "teltonika-fmb-03-29-common" &&
+ profile.CertificationStatus ==
+ UnitTrackingCertificationStatus.Candidate &&
+ !profile.IsSelectable &&
+ profile.CertifiedTransports.Count == 0 &&
+ profile.SupportedAuthModes.Count == 0);
+ teltonika.Should().OnlyContain(profile =>
+ profile.SupportedTransports
+ .OrderBy(transport => transport)
+ .SequenceEqual(
+ new[] { "Tcp", "Udp" }));
+ var ioMap = UnitTrackingCatalog.GetIoMap(
+ "teltonika-fmb-03-29-common");
+ ioMap.Should().NotBeNull();
+ ioMap.Mappings.Select(mapping => mapping.AvlId)
+ .Should().BeEquivalentTo(
+ new[] { 66, 182, 239, 240 });
+ }
+
+ [Test]
+ public async Task GetProfilesAsync_WaveTwo_RemainsUnselectableWithoutUnverifiedMappings()
+ {
+ // Arrange
+ var service = new UnitTrackingCatalogService();
+ var waveTwoKeys = new[]
+ {
+ "teltonika-fmm003",
+ "teltonika-fmc125",
+ "teltonika-fmm125",
+ "teltonika-fmc150",
+ "teltonika-fmm150",
+ "teltonika-fmc230",
+ "teltonika-fmm230",
+ "jimi-jm-vl01",
+ "jimi-jm-vl02",
+ "jimi-jm-vl04"
+ };
+
+ // Act
+ var profiles = await service.GetProfilesAsync();
+ var waveTwo = profiles
+ .Where(profile => waveTwoKeys.Contains(
+ profile.Key,
+ StringComparer.Ordinal))
+ .ToList();
+
+ // Assert
+ waveTwo.Should().HaveCount(waveTwoKeys.Length);
+ waveTwo.Select(profile => profile.Key)
+ .Should().BeEquivalentTo(waveTwoKeys);
+ waveTwo.Should().OnlyContain(profile =>
+ profile.CertificationStatus ==
+ UnitTrackingCertificationStatus.Candidate &&
+ !profile.IsSelectable &&
+ profile.CertifiedTransports.Count == 0 &&
+ profile.SupportedAuthModes.Count == 0 &&
+ string.IsNullOrWhiteSpace(profile.IoMapKey) &&
+ profile.ProtocolDocumentVersion.Contains(
+ "certification pending",
+ StringComparison.Ordinal));
+ waveTwo.Where(profile =>
+ profile.ManufacturerKey == "teltonika")
+ .Should().OnlyContain(profile =>
+ profile.ProtocolKey == "teltonika-codec8" &&
+ profile.DecoderVariant == "codec8-or-8e" &&
+ profile.SupportedTransports
+ .OrderBy(transport => transport)
+ .SequenceEqual(
+ new[] { "Tcp", "Udp" }));
+ waveTwo.Where(profile =>
+ profile.ManufacturerKey == "jimi")
+ .Should().OnlyContain(profile =>
+ profile.ProtocolKey == "gt06" &&
+ profile.DecoderVariant.EndsWith(
+ "-unverified",
+ StringComparison.Ordinal) &&
+ profile.SupportedTransports.SequenceEqual(
+ new[] { "Tcp" }));
+ }
+
+ [Test]
+ public async Task GetProfilesAsync_QueclinkAndJimiWaveOne_RemainTcpCandidates()
+ {
+ // Arrange
+ var service = new UnitTrackingCatalogService();
+
+ // Act
+ var profiles = await service.GetProfilesAsync();
+ var candidates = profiles
+ .Where(profile =>
+ profile.Key == "queclink-gv57mg" ||
+ profile.Key == "queclink-gv350mg" ||
+ profile.Key == "queclink-gv500ma" ||
+ profile.Key == "jimi-vl103m" ||
+ profile.Key == "jimi-jm-vl03")
+ .ToList();
+
+ // Assert
+ candidates.Select(profile => profile.Key)
+ .Should().BeEquivalentTo(
+ "queclink-gv57mg",
+ "queclink-gv350mg",
+ "queclink-gv500ma",
+ "jimi-vl103m",
+ "jimi-jm-vl03");
+ candidates.Should().OnlyContain(profile =>
+ profile.TransportType ==
+ UnitTrackingTransportType.NativeTcpUdp &&
+ profile.CertificationStatus ==
+ UnitTrackingCertificationStatus.Candidate &&
+ !profile.IsSelectable &&
+ profile.CertifiedTransports.Count == 0 &&
+ profile.SupportedAuthModes.Count == 0 &&
+ profile.SupportedTransports.SequenceEqual(
+ new[] { "Tcp" }));
+ candidates.Where(profile =>
+ profile.ManufacturerKey == "queclink")
+ .Should().OnlyContain(profile =>
+ profile.ProtocolKey == "queclink-attrack" &&
+ profile.DecoderVariant ==
+ "gl200-text-bounded");
+ candidates.Where(profile =>
+ profile.ManufacturerKey == "jimi")
+ .Should().OnlyContain(profile =>
+ profile.ProtocolKey == "gt06" &&
+ profile.DecoderVariant.StartsWith(
+ "gt06-",
+ StringComparison.Ordinal));
}
[Test]
diff --git a/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs
index 56fd00ec9..6986341b6 100644
--- a/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs
+++ b/Tests/Resgrid.Tests/Services/UnitTrackingIngressServiceTests.cs
@@ -214,6 +214,63 @@ public async Task AcceptAsync_ConfirmedPublishFailure_ReturnsUnavailable()
result.Accepted.Should().Be(0);
}
+ [Test]
+ public async Task AcceptHeartbeatAsync_ValidBinding_UpdatesLastSeenWithoutPublishing()
+ {
+ // Arrange
+ var source = Source();
+ source.ReportedDeviceIdentifier = "DEVICE-1234";
+
+ // Act
+ var result = await _service.AcceptHeartbeatAsync(
+ source,
+ _receivedOn);
+
+ // Assert
+ result.Status.Should().Be(
+ TrackingIngressStatus.Accepted);
+ result.ReceivedOn.Should().Be(_receivedOn);
+ source.Device.LastSeenOn.Should().Be(_receivedOn);
+ source.Device.LastStatus.Should().Be(
+ (int)UnitTrackingDeviceStatus.Online);
+ _eventProvider.Verify(
+ provider =>
+ provider.EnqueueUnitLocationEventsAsync(
+ It.IsAny>(),
+ It.IsAny()),
+ Times.Never);
+ }
+
+ [Test]
+ public async Task AcceptHeartbeatAsync_TenantBindingMismatch_RejectsHeartbeat()
+ {
+ // Arrange
+ _unitsService
+ .Setup(service =>
+ service.GetUnitByIdAsync(UnitId))
+ .ReturnsAsync(
+ new Unit
+ {
+ UnitId = UnitId,
+ DepartmentId = 999
+ });
+
+ // Act
+ var result = await _service.AcceptHeartbeatAsync(
+ Source(),
+ _receivedOn);
+
+ // Assert
+ result.Status.Should().Be(
+ TrackingIngressStatus.Invalid);
+ _eventProvider.Verify(
+ provider =>
+ provider.EnqueueUnitLocationEventsAsync(
+ It.IsAny>(),
+ It.IsAny()),
+ Times.Never);
+ }
+
private AuthenticatedTrackingSource Source()
{
return new AuthenticatedTrackingSource
diff --git a/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs b/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs
new file mode 100644
index 000000000..136245605
--- /dev/null
+++ b/Tests/Resgrid.Tests/Workers/UnitTrackingRetentionLogicTests.cs
@@ -0,0 +1,263 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Moq;
+using NUnit.Framework;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Workers.Framework.Logic;
+
+namespace Resgrid.Tests.Workers
+{
+ [TestFixture]
+ public class UnitTrackingRetentionLogicTests
+ {
+ private bool _originalEnabled;
+ private int _originalBatchSize;
+ private int _originalMaximumRows;
+
+ [SetUp]
+ public void SetUp()
+ {
+ _originalEnabled =
+ UnitTrackingConfig
+ .LocationRetentionWorkerEnabled;
+ _originalBatchSize =
+ UnitTrackingConfig
+ .LocationRetentionBatchSize;
+ _originalMaximumRows =
+ UnitTrackingConfig
+ .LocationRetentionMaxRowsPerRun;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ UnitTrackingConfig
+ .LocationRetentionWorkerEnabled =
+ _originalEnabled;
+ UnitTrackingConfig
+ .LocationRetentionBatchSize =
+ _originalBatchSize;
+ UnitTrackingConfig
+ .LocationRetentionMaxRowsPerRun =
+ _originalMaximumRows;
+ }
+
+ [Test]
+ public async Task Process_WhenDisabled_DoesNotReadOrDeleteLocations()
+ {
+ // Arrange
+ UnitTrackingConfig
+ .LocationRetentionWorkerEnabled = false;
+ var departments =
+ new Mock(
+ MockBehavior.Strict);
+ var settings =
+ new Mock(
+ MockBehavior.Strict);
+ var retention =
+ new Mock(
+ MockBehavior.Strict);
+ var logic = new UnitTrackingRetentionLogic(
+ departments.Object,
+ settings.Object,
+ retention.Object);
+
+ // Act
+ var result = await logic.Process(
+ CancellationToken.None);
+
+ // Assert
+ result.Item1.Should().BeTrue();
+ result.Item2.Should().Contain("disabled");
+ departments.VerifyNoOtherCalls();
+ settings.VerifyNoOtherCalls();
+ retention.VerifyNoOtherCalls();
+ }
+
+ [Test]
+ public async Task Process_WithDepartmentSettings_UsesBoundedPerDepartmentCutoffs()
+ {
+ // Arrange
+ UnitTrackingConfig
+ .LocationRetentionWorkerEnabled = true;
+ UnitTrackingConfig
+ .LocationRetentionBatchSize = 2;
+ UnitTrackingConfig
+ .LocationRetentionMaxRowsPerRun = 10;
+ var runUtc = new DateTime(
+ 2026,
+ 7,
+ 26,
+ 12,
+ 0,
+ 0,
+ DateTimeKind.Utc);
+ var departments =
+ new Mock();
+ departments
+ .Setup(repository => repository.GetAllAsync())
+ .ReturnsAsync(
+ new[]
+ {
+ new Department { DepartmentId = 2 },
+ new Department { DepartmentId = 1 },
+ new Department { DepartmentId = 1 },
+ new Department { DepartmentId = 0 }
+ });
+ var settings =
+ new Mock();
+ settings
+ .Setup(service =>
+ service
+ .GetHardwareTrackingLocationRetentionDaysAsync(
+ It.IsAny(),
+ false))
+ .ReturnsAsync(
+ (int departmentId, bool bypassCache) =>
+ departmentId == 1 ? 30 : 60);
+ var calls =
+ new List();
+ var results = new Queue(
+ new[] { 2, 0, 1 });
+ var retention =
+ new Mock();
+ retention
+ .Setup(repository =>
+ repository
+ .DeleteHardwareLocationsBeforeAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback(
+ (int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken) =>
+ calls.Add(
+ new RetentionCall(
+ departmentId,
+ cutoffUtc,
+ batchSize)))
+ .ReturnsAsync(() => results.Dequeue());
+ var logic = new UnitTrackingRetentionLogic(
+ departments.Object,
+ settings.Object,
+ retention.Object);
+
+ // Act
+ var result = await logic.Process(
+ CancellationToken.None,
+ runUtc);
+
+ // Assert
+ result.Item1.Should().BeTrue();
+ result.Item2.Should().Contain(
+ "deleted 3 hardware location(s) across 2 department(s)");
+ calls.Should().Equal(
+ new RetentionCall(
+ 1,
+ runUtc.AddDays(-30),
+ 2),
+ new RetentionCall(
+ 1,
+ runUtc.AddDays(-30),
+ 2),
+ new RetentionCall(
+ 2,
+ runUtc.AddDays(-60),
+ 2));
+ }
+
+ [Test]
+ public async Task Process_WhenMaximumRowsReached_StopsBeforeNextDepartment()
+ {
+ // Arrange
+ UnitTrackingConfig
+ .LocationRetentionWorkerEnabled = true;
+ UnitTrackingConfig
+ .LocationRetentionBatchSize = 2;
+ UnitTrackingConfig
+ .LocationRetentionMaxRowsPerRun = 3;
+ var departments =
+ new Mock();
+ departments
+ .Setup(repository => repository.GetAllAsync())
+ .ReturnsAsync(
+ new[]
+ {
+ new Department { DepartmentId = 1 },
+ new Department { DepartmentId = 2 }
+ });
+ var settings =
+ new Mock();
+ settings
+ .Setup(service =>
+ service
+ .GetHardwareTrackingLocationRetentionDaysAsync(
+ 1,
+ false))
+ .ReturnsAsync(30);
+ var requestedBatchSizes = new List();
+ var results = new Queue(
+ new[] { 2, 1 });
+ var retention =
+ new Mock();
+ retention
+ .Setup(repository =>
+ repository
+ .DeleteHardwareLocationsBeforeAsync(
+ 1,
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback(
+ (int departmentId,
+ DateTime cutoffUtc,
+ int batchSize,
+ CancellationToken cancellationToken) =>
+ requestedBatchSizes.Add(batchSize))
+ .ReturnsAsync(() => results.Dequeue());
+ var logic = new UnitTrackingRetentionLogic(
+ departments.Object,
+ settings.Object,
+ retention.Object);
+
+ // Act
+ var result = await logic.Process(
+ CancellationToken.None,
+ new DateTime(
+ 2026,
+ 7,
+ 26,
+ 12,
+ 0,
+ 0,
+ DateTimeKind.Utc));
+
+ // Assert
+ result.Item1.Should().BeTrue();
+ result.Item2.Should().Contain(
+ "deleted 3 hardware location(s) across 1 department(s)");
+ requestedBatchSizes.Should().Equal(2, 1);
+ settings.Verify(
+ service =>
+ service
+ .GetHardwareTrackingLocationRetentionDaysAsync(
+ 2,
+ false),
+ Times.Never);
+ }
+
+ private readonly record struct RetentionCall(
+ int DepartmentId,
+ DateTime CutoffUtc,
+ int BatchSize);
+ }
+}
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md b/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md
new file mode 100644
index 000000000..8055a4922
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/README.md
@@ -0,0 +1,8 @@
+# GT06 fixtures
+
+These sanitized binary messages are copied from the Apache-licensed Traccar v6.14.5
+`Gt06ProtocolDecoderTest` at commit
+`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. They cover login, a standard
+GPS/LBS frame, the JM-VL03-associated `0xA0` layout, and status/heartbeat framing.
+They are upstream interoperability fixtures, not captured target-device evidence and
+not certification evidence.
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex
new file mode 100644
index 000000000..5a1057abb
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/heartbeat.hex
@@ -0,0 +1 @@
+78780A13C40604000201298F5B0D0A
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex
new file mode 100644
index 000000000..7fd9a22bd
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-jm-vl03-a0.hex
@@ -0,0 +1 @@
+787829A0170704112226CF0163FE7C0420F6F000091302D402000091290000000007186B8F01030001460D010D0A
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex
new file mode 100644
index 000000000..ea4eabc1a
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/location-standard.hex
@@ -0,0 +1 @@
+787822220F0C1D023305C9027AC8180C46586000140001CC00287D001F71000001000820860D0A
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex b/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex
new file mode 100644
index 000000000..92ae7e24e
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Gt06/login.hex
@@ -0,0 +1 @@
+78780D01086471700328358100093F040D0A
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md b/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md
new file mode 100644
index 000000000..c840467cd
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/README.md
@@ -0,0 +1,7 @@
+# Queclink fixtures
+
+These sanitized ASCII messages are copied from the Apache-licensed Traccar v6.14.5
+`Gl200TextProtocolDecoderTest` at commit
+`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`. They exercise common `+RESP`,
+`+BUFF`, ignition, and heartbeat behavior. They are upstream interoperability fixtures,
+not captured evidence from the three target devices and not certification evidence.
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt
new file mode 100644
index 000000000..eed8a4ea1
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gteri-buffered.txt
@@ -0,0 +1 @@
++BUFF:GTERI,358803,862364030261132,gv200,00000002,,10,1,1,43.4,160,592.2,46.723488,24.590880,20240817131155,0420,0001,01AE,393D,00,19273.0,,,12,,05,00,2,0,20240817131205,C81A$
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt
new file mode 100644
index 000000000..d336c10e2
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtfri-live.txt
@@ -0,0 +1 @@
++RESP:GTFRI,DF0200,868487004353181,cv100,14051,10,1,0,0.0,0,264.1,114.015515,22.537178,20210608064328,0460,0001,25F8,061A7D02,,0.0,,,,100,21,,,,20210608144354,32DB$
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt
new file mode 100644
index 000000000..581d61c18
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/gtign-live.txt
@@ -0,0 +1 @@
++RESP:GTIGN,BD0221,861778061565387,CV200,,0,140944,0,0.0,358,17.6,-2.223488,51.882342,20251222020123,0234,0015,6093,000F830E,,,5810.9,20251222070035,20251222070035,C60E$
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt b/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt
new file mode 100644
index 000000000..10b7943db
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Queclink/heartbeat.txt
@@ -0,0 +1 @@
++ACK:GTHBD,1A0401,135790246811220,,20100214093254,11F0$
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md
new file mode 100644
index 000000000..eab60369d
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/README.md
@@ -0,0 +1,26 @@
+# Teltonika generated protocol fixtures
+
+These fixtures were independently generated from the public Teltonika Codec8/8E packet
+structure documented at:
+
+https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols
+
+They contain no customer or captured device data and do not constitute hardware or
+firmware certification.
+
+- `codec8-location.hex`: one Codec8 record for San Francisco at
+ `2024-01-02T03:04:05Z`, 15 m altitude, 90° heading, 8 satellites, and 36 km/h.
+- `codec8e-location.hex`: one Codec8 Extended record for Berlin at the same timestamp,
+ 34 m altitude, 270° heading, 11 satellites, and 72 km/h.
+- `codec8-udp-location.hex`: the Codec8 data array above wrapped in the documented UDP
+ channel using channel packet ID `0xCAFE`, AVL packet ID `0x05`, and the generated
+ test IMEI.
+- `codec8e-udp-location.hex`: the Codec8 Extended data array above wrapped in the
+ documented UDP channel using channel packet ID `0xBEEF`, AVL packet ID `0x07`, and
+ the same generated test IMEI.
+- `codec8-io-location.hex`: one generated Codec8 record with the shared Wave 1
+ allowlisted values for HDOP, external voltage, ignition, and movement.
+
+The original Codec8 and Codec8 Extended records deliberately contain zero I/O elements.
+The I/O fixture validates only the documented shared Wave 1 map and does not represent
+captured model/firmware evidence.
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex
new file mode 100644
index 000000000..f17a7d7d1
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-io-location.hex
@@ -0,0 +1 @@
+000000000000002B08010000018CC820D88801B70848301683FE08000F005A080024EF0402EF01F001024230D4B6000C000001000071FF
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex
new file mode 100644
index 000000000..fdd276179
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-location.hex
@@ -0,0 +1 @@
+000000000000002108010000018CC820D88801B70848301683FE08000F005A0800240000000000000100000930
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex
new file mode 100644
index 000000000..9c8e70f11
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8-udp-location.hex
@@ -0,0 +1 @@
+0036CAFE0105000F33353633303730343234343130313308010000018CC820D88801B70848301683FE08000F005A08002400000000000001
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex
new file mode 100644
index 000000000..78b227cdf
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-location.hex
@@ -0,0 +1 @@
+00000000000000298E010000018CC820D8880207FD70D01F4DEA800022010E0B00480000000000000000000000000000010000BC2D
diff --git a/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex
new file mode 100644
index 000000000..2ab8be16b
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Data/Teltonika/codec8e-udp-location.hex
@@ -0,0 +1 @@
+003EBEEF0107000F3335363330373034323434313031338E010000018CC820D8880207FD70D01F4DEA800022010E0B0048000000000000000000000000000001
diff --git a/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs b/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs
new file mode 100644
index 000000000..2dd92f2d0
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Health/TrackingGatewayHealthCheckTests.cs
@@ -0,0 +1,179 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FluentAssertions;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using NUnit.Framework;
+using Resgrid.Model.Tracking;
+using Resgrid.Providers.Tracking.Protocols;
+using Resgrid.TrackerGateway.Health;
+using Resgrid.TrackerGateway.Hosting;
+
+namespace Resgrid.Tracking.Tests.Health
+{
+ [TestFixture]
+ public class TrackingGatewayHealthCheckTests
+ {
+ [Test]
+ public async Task CheckHealthAsync_RequiredListenerNotBound_IsUnhealthy()
+ {
+ // Arrange
+ var definition = new TrackingListenerDefinition(
+ "synthetic-v1",
+ TrackingSocketTransport.Tcp,
+ 5000);
+ var state = new TrackingGatewayReadinessState();
+ state.Initialize(new TrackingListenerPlan(new[] { definition }));
+ var healthCheck = new TrackingGatewayReadinessHealthCheck(state);
+
+ // Act
+ var result = await healthCheck.CheckHealthAsync(
+ new HealthCheckContext());
+
+ // Assert
+ result.Status.Should().Be(HealthStatus.Unhealthy);
+ }
+
+ [Test]
+ public async Task CheckHealthAsync_AllRequiredListenersBound_IsHealthy()
+ {
+ // Arrange
+ var definition = new TrackingListenerDefinition(
+ "synthetic-v1",
+ TrackingSocketTransport.Tcp,
+ 5000);
+ var state = new TrackingGatewayReadinessState();
+ state.Initialize(new TrackingListenerPlan(new[] { definition }));
+ state.MarkBound(definition);
+ var healthCheck = new TrackingGatewayReadinessHealthCheck(state);
+
+ // Act
+ var result = await healthCheck.CheckHealthAsync(
+ new HealthCheckContext());
+
+ // Assert
+ result.Status.Should().Be(HealthStatus.Healthy);
+ }
+
+ [Test]
+ public void MetricsWriter_ReadinessSnapshot_EmitsBoundedGatewayGauges()
+ {
+ // Arrange
+ var snapshot = new TrackingGatewayReadinessSnapshot(
+ expectedListeners: 2,
+ boundListeners: 1,
+ isReady: false,
+ hasFailure: false);
+
+ // Act
+ var metrics = TrackingGatewayMetricsWriter.Write(snapshot);
+
+ // Assert
+ metrics.Should().Contain(
+ "resgrid_tracker_gateway_listeners_expected 2");
+ metrics.Should().Contain(
+ "resgrid_tracker_gateway_listeners_bound 1");
+ metrics.Should().Contain(
+ "resgrid_tracker_gateway_ready 0");
+ metrics.Should().NotContain(
+ "resgrid_tracker_gateway_connections_limit");
+ }
+
+ [Test]
+ public void MetricsWriter_ConfiguredConnectionLimit_EmitsCapacityGauge()
+ {
+ // Arrange
+ var snapshot = new TrackingGatewayReadinessSnapshot(
+ expectedListeners: 1,
+ boundListeners: 1,
+ isReady: true,
+ hasFailure: false);
+
+ // Act
+ var metrics = TrackingGatewayMetricsWriter.Write(
+ snapshot,
+ connectionLimit: 2500);
+
+ // Assert
+ metrics.Should().Contain(
+ "resgrid_tracker_gateway_connections_limit 2500");
+ }
+
+ [Test]
+ public void MetricsWriter_TrackingActivity_EmitsBoundedLabelsAndHistograms()
+ {
+ // Arrange
+ var definition = new TrackingListenerDefinition(
+ "synthetic-v1",
+ TrackingSocketTransport.Tcp,
+ 5000);
+ var activity = new TrackingGatewayMetrics();
+ activity.ConnectionStarted(definition);
+ activity.ConnectionCompleted(
+ definition,
+ "completed");
+ activity.RecordIngressMessage(
+ definition,
+ new ProtocolMessage
+ {
+ MessageType = ProtocolMessageType.Positions,
+ Positions =
+ new List
+ {
+ new CanonicalTrackingPosition()
+ }
+ },
+ new TrackingAcceptance
+ {
+ Status = TrackingAcceptanceStatus.Accepted,
+ AcceptedPositions = 1
+ });
+ activity.RecordParseFailure(
+ definition.ProtocolKey,
+ "device-specific-parser-reason");
+ activity.RecordAuthFailure(
+ definition.Transport,
+ "device-specific-auth-reason");
+ activity.ObserveQueuePublishDuration(
+ definition.Transport,
+ TimeSpan.FromMilliseconds(50));
+ activity.ObserveFrameBytes(
+ definition.ProtocolKey,
+ 128);
+ activity.ObserveSessionDuration(
+ definition.ProtocolKey,
+ TimeSpan.FromSeconds(2));
+
+ // Act
+ var metrics = TrackingGatewayMetricsWriter.Write(
+ new TrackingGatewayReadinessSnapshot(
+ expectedListeners: 1,
+ boundListeners: 1,
+ isReady: true,
+ hasFailure: false),
+ activity);
+
+ // Assert
+ metrics.Should().Contain(
+ "resgrid_tracking_connections_current{protocol=\"synthetic-v1\",transport=\"tcp\"} 0");
+ metrics.Should().Contain(
+ "resgrid_tracking_connections_total{protocol=\"synthetic-v1\",outcome=\"completed\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_ingress_messages_total{transport=\"tcp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_positions_total{transport=\"tcp\",protocol=\"synthetic-v1\",outcome=\"accepted\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_parse_failures_total{protocol=\"synthetic-v1\",reason=\"other\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_auth_failures_total{transport=\"tcp\",reason=\"other\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_queue_publish_duration_seconds_count{transport=\"tcp\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_frame_bytes_count{protocol=\"synthetic-v1\"} 1");
+ metrics.Should().Contain(
+ "resgrid_tracking_session_duration_seconds_count{protocol=\"synthetic-v1\"} 1");
+ metrics.Should().NotContain(
+ "device-specific");
+ }
+ }
+}
diff --git a/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs b/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs
new file mode 100644
index 000000000..7f9bd5c83
--- /dev/null
+++ b/Tests/Resgrid.Tracking.Tests/Hosting/TrackingListenerPlanBuilderTests.cs
@@ -0,0 +1,479 @@
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using NUnit.Framework;
+using Resgrid.Providers.Tracking.Protocols;
+using Resgrid.TrackerGateway.Hosting;
+using Resgrid.TrackerGateway.Listeners;
+
+namespace Resgrid.Tracking.Tests.Hosting
+{
+ [TestFixture]
+ public class TrackingListenerPlanBuilderTests
+ {
+ [Test]
+ public void Build_NativeGatewayDisabled_ReturnsEmptyPlan()
+ {
+ // Arrange
+ var settings = CreateSettings(
+ trackingEnabled: false,
+ nativeGatewayEnabled: false,
+ credentialPepper: null,
+ protocols: new[]
+ {
+ new TrackingProtocolListenerSettings(
+ "synthetic-v1",
+ true,
+ true,
+ 5000,
+ true,
+ 5000)
+ });
+ var registry = new TrackingProtocolModuleRegistry(
+ Array.Empty());
+ var factory = new SupportingListenerFactory();
+
+ // Act
+ var plan = new TrackingListenerPlanBuilder().Build(
+ settings,
+ registry,
+ factory);
+
+ // Assert
+ plan.Listeners.Should().BeEmpty();
+ }
+
+ [Test]
+ public void Build_NativeGatewayEnabledWithoutMasterSwitch_RejectsConfiguration()
+ {
+ // Arrange
+ var settings = CreateSettings(
+ trackingEnabled: false,
+ protocols: Array.Empty());
+ var registry = new TrackingProtocolModuleRegistry(
+ Array.Empty());
+
+ // Act
+ Action act = () => new TrackingListenerPlanBuilder().Build(
+ settings,
+ registry,
+ new SupportingListenerFactory());
+
+ // Assert
+ act.Should().Throw()
+ .Which.Errors.Should().Contain(
+ error => error.Contains(
+ "UnitTrackingConfig.Enabled",
+ StringComparison.Ordinal));
+ }
+
+ [Test]
+ public void Build_NativeGatewayEnabledWithoutPepper_RejectsConfiguration()
+ {
+ // Arrange
+ var settings = CreateSettings(
+ credentialPepper: " ",
+ protocols: Array.Empty());
+ var registry = new TrackingProtocolModuleRegistry(
+ Array.Empty());
+
+ // Act
+ Action act = () => new TrackingListenerPlanBuilder().Build(
+ settings,
+ registry,
+ new SupportingListenerFactory());
+
+ // Assert
+ act.Should().Throw()
+ .Which.Errors.Should().Contain(
+ error => error.Contains(
+ "CredentialPepper",
+ StringComparison.Ordinal));
+ }
+
+ [Test]
+ public void Build_FrameLimitAboveSafetyCeiling_RejectsConfiguration()
+ {
+ // Arrange
+ var settings = CreateSettings(
+ maxFrameBytes: 1024 * 1024 + 1,
+ protocols:
+ Array.Empty