Why a driver, and what ISG actually is

Security Center ships native support for a fixed list of perimeter intrusion detection systems. If the field hardware on your project isn't on that list — and for most electric-fence and energizer-based perimeter systems it won't be — the only way to get it onto the map and into the alarm engine is a driver you write yourself.

There are two integration surfaces for this, and they're complementary, not alternatives. The ISG driver is a Docker container running server-side, always on, whether or not an operator is logged in. It owns polling the field system, persisting events, and feeding the alarm engine and map. The Workspace SDK plugin, by contrast, is a WPF module loaded inside Security Desk — client-side, UI-only, and only alive while an operator has it open. It owns operator controls and vendor-specific telemetry the RSA schema was never designed to carry.

State this plainly to anyone scoping the work: the ISG/RSA path is what makes a fence controller a first-class citizen on the map and in the alarm engine. A Workspace plugin cannot substitute for it — it can only add UI on top once the driver exists. This post covers the ISG driver end to end; a second part covers the Workspace SDK plugin.

Data path, field system to operator
Field system
(fence controller)
Driver container
(your code)
ISG
RabbitMQ transport
ISG output driver
RSA plugin role
Security Desk
map · alarm engine

Gotcha — Teams new to ISG often try to build the Workspace plugin first because it's the visible part. Do it in the other order. Without the driver, RSA has no device to attach a target to, and the Workspace plugin has nothing to enrich.

The two contracts

This is the single most important framing for the whole project. Your driver implements two REST contracts, running in opposite directions, inside the same container:

  • Client contract (outbound) — your driver calls ISG: authenticate, send a heartbeat/status message, push fence intrusions, push fence states, push generic events.
  • Server contract (inbound) — ISG calls your driver: get and post configuration, get zones, get fences, get device statuses, activate/deactivate a device, acknowledge an intrusion.

On top of both, the same container serves a small configuration web page, with an auto-login token so ISG can embed it in an iframe inside Config Tool without prompting the operator for a second set of credentials.

Outbound — driver calls ISG
Driver
auth · heartbeat · push intrusions/states/events
ISG
Inbound — ISG calls driver
ISG
config get/post · get zones/fences · status · ack
Driver

Security requirements to bake in from day one: the driver exposes HTTPS on port 443 inside the container (ISG maps the external port), and the inbound API is protected with either BasicAuthentication (recommended for most deployments) or a BearerToken scheme you issue yourself.

Gotcha — It's easy to conflate the two contracts while designing your DTOs and end up with one bloated "sync" model shared by both directions. Keep them as separate namespaces (Outbound.* / Inbound.*) from the first commit — ISG's schemas drift independently between versions, and a shared model becomes a breaking change waiting to happen.

Bootstrap: the environment variable and password decryption

ISG injects a single environment variable, GENETEC_PIDS_GATEWAY, into the container at creation time. Its shape (sanitised, but structurally accurate):

JSON — GENETEC_PIDS_GATEWAY (sanitised)
{
  "Id": "6605011a-617c-45df-bdf9-6d5f3e4a07c2",
  "GatewayHostname": "192.0.2.10",
  "GatewayPort": 4242,
  "GatewayEncryptedPassword": "jkspPVKQGmfv",
  "ExposedPorts": [],
  "GoogleMapsAPIKey": ""
}

Id is both the container instance ID and the username you authenticate to ISG with — there's no separate service account. GatewayEncryptedPassword arrives encrypted and must be decrypted before use. ExposedPorts is how you discover the host-side port mapping if your driver needs a raw TCP/UDP listener for the field system itself — declare those ports in the ISG driver-creation wizard first, then read the actual mapping back here at runtime. Don't hardcode a port your driver assumes it owns.

C# — GatewayOptions bootstrap
public sealed class GatewayOptions
{
    public Guid Id { get; init; }
    public string GatewayHostname { get; init; } = "";
    public int GatewayPort { get; init; }
    public string Password { get; init; } = "";
    public IReadOnlyList<int> ExposedPorts { get; init; } = Array.Empty<int>();

    public Uri BaseUrl => new($"https://{GatewayHostname}:{GatewayPort}");

    public static GatewayOptions FromEnvironment(IGatewayPasswordCipher cipher, ILogger logger)
    {
        var raw = Environment.GetEnvironmentVariable("GENETEC_PIDS_GATEWAY")
            ?? throw new InvalidOperationException("GENETEC_PIDS_GATEWAY not set — container was not started by ISG.");

        var dto = JsonSerializer.Deserialize<GatewayEnvDto>(raw)
            ?? throw new InvalidOperationException("GENETEC_PIDS_GATEWAY failed to parse.");

        var options = new GatewayOptions
        {
            Id = dto.Id,
            GatewayHostname = dto.GatewayHostname,
            GatewayPort = dto.GatewayPort,
            Password = cipher.Decrypt(dto.GatewayEncryptedPassword),
            ExposedPorts = dto.ExposedPorts ?? Array.Empty<int>()
        };

        logger.LogInformation("Resolved gateway base URL {BaseUrl} (id {Id})", options.BaseUrl, options.Id);
        return options;
    }
}

// Program.cs
builder.Services.AddSingleton(sp =>
    GatewayOptions.FromEnvironment(sp.GetRequiredService<IGatewayPasswordCipher>(),
                                    sp.GetRequiredService<ILogger<GatewayOptions>>()));
builder.Services.AddSingleton<IGatewayClient, GatewayClient>();

Gotcha — Do not attempt to authenticate before the env var is parsed. A container that starts faster than ISG finishes provisioning will silently loop on 401s that look like a credentials bug but are actually a startup race. Retry with backoff, and log the decoded (never the raw) gateway URL exactly once at startup so a support ticket can confirm the driver saw the address it expected.

Talking to ISG: auth, headers, heartbeat

Authenticate against GET /api/v1/Authenticate?username={Id}&password={decryptedPassword}, which returns a token valid for one day, plus a version string:

JSON — authenticate response
{ "Token": "xxxxxxxx", "Version": "1.2.0.130" }

That Version field carries a rule worth writing down before you build anything else: if it's absent, you're talking to ISG 1.1.x, and calling /api/v1.2/... endpoints or anything gated behind a newer capability will fail outright. Wrap this in a small helper and a capability flag on the client rather than sprinkling version checks through call sites:

C# — version gate
public sealed class GatewayVersion
{
    public static bool IsAtLeast(string? reported, int major, int minor)
    {
        if (string.IsNullOrEmpty(reported)) return false; // absent = 1.1.x
        var parts = reported.Split('.');
        return int.Parse(parts[0]) > major ||
               (int.Parse(parts[0]) == major && int.Parse(parts[1]) >= minor);
    }
}

Cache the token with a refresh margin — renew at, say, 23 hours — rather than waiting for a 401 to trigger a re-auth. Reacting to 401s adds a failed round trip to every token expiry and complicates retry logic for no benefit.

Every outbound request also carries a ContainerInstance header set to the same Id from the environment variable, and POST bodies are JSON throughout. Attach both the bearer token and the header in one place so no call site can forget either:

C# — DelegatingHandler for auth + container header
public sealed class GatewayAuthHandler : DelegatingHandler
{
    private readonly IGatewayTokenCache _tokens;
    private readonly GatewayOptions _options;

    public GatewayAuthHandler(IGatewayTokenCache tokens, GatewayOptions options)
    {
        _tokens = tokens;
        _options = options;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var token = await _tokens.GetOrRefreshAsync(ct);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
        request.Headers.Add("ContainerInstance", _options.Id.ToString());
        return await base.SendAsync(request, ct);
    }
}

The heartbeat — sometimes called the status message — is the call that decides whether ISG considers your driver alive at all. It declares the driver's own callback URL, the credentials ISG should use for the inbound contract, and, critically, the device list. Two rules matter more than anything else in this section:

  • The DeviceId values sent in the heartbeat are the join key for every message that follows. Intrusions, fence states, and events all reference a device by this string, and a mismatch means the message is silently dropped — accepted with a 2xx, never surfaced anywhere.
  • Use a deterministic, parseable ID convention such as {deviceType}_{deviceNumber} (e.g. fence_01). It costs nothing to generate and means an inbound acknowledge call can be parsed straight back to a field-system address without a lookup table.

Send the heartbeat on a timer, roughly every 30 seconds. If it stops, the driver drops to Disconnected in ISG regardless of how healthy the rest of the container actually is — a slow database write in an unrelated background task can take a fully functional driver offline in the UI if it shares a thread pool with the heartbeat loop.

C# — GatewayClient (heartbeat + push methods)
public sealed class GatewayClient : IGatewayClient
{
    private readonly HttpClient _http;
    private readonly ILogger<GatewayClient> _logger;

    public GatewayClient(HttpClient http, ILogger<GatewayClient> logger)
    {
        _http = http;
        _logger = logger;
    }

    public async Task SendHeartbeatAsync(HeartbeatStatus status, CancellationToken ct)
    {
        var response = await _http.PostAsJsonAsync("api/v1/Status", status, ct);
        response.EnsureSuccessStatusCode();
    }

    public async Task SendFenceIntrusionAsync(FenceIntrusion intrusion, CancellationToken ct)
    {
        var response = await _http.PostAsJsonAsync("api/v1/Fence/Intrusions", new[] { intrusion }, ct);
        if (!response.IsSuccessStatusCode)
            _logger.LogWarning("Fence intrusion push rejected: {Status}", response.StatusCode);
    }

    // SendFenceStatusAsync, SendEventAsync, AuthenticateAsync follow the same
    // shape — POST/GET through the shared HttpClient, EnsureSuccessStatusCode,
    // structured logging on non-2xx. Elided for brevity.
}

Gotcha — The DeviceId join key is worth stating twice because it's the single most common reason a working integration looks broken: everything authenticates, the heartbeat is green, and targets still never appear. Before debugging anything else, diff the device IDs your heartbeat sent against the ones your intrusion payloads reference.

Modelling a fence system: devices, zones, fences

ISG draws a real distinction between two event shapes, and picking the wrong one produces payloads that validate but never render sensibly. Intrusion is for tracking systems — radar, lidar, video analytics — and is always geolocated, carrying speed, direction, and classification. FenceIntrusion is for fence devices specifically: no speed or direction, and position expressed as GPS, a relative distance, a percentage along the fence, or nothing at all.

JSON — fence intrusion payload
{
  "Id": "9e530a20-2320-4ab2-a4cd-0340b3abd4a7",
  "Date": "2026-08-22T21:24:42.2852653Z",
  "DeviceId": "fence_01",
  "LocationType": 0,
  "Location": { "Latitude": 45.4838, "Longitude": -73.7609 },
  "RelativePosition": null,
  "Altitude": null,
  "Type": 4,
  "Size": null,
  "Description": null,
  "ThreatLevel": 50,
  "Identification": null
}

LocationType controls which position field you're expected to fill: 0 is GPS, and you populate Location; 1 is metres from the fence start, filled into RelativePosition; 2 is a percentage from 0–100 along the fence, also in RelativePosition; 3 means no location at all, and both fields stay empty. Type maps to FenceTargetType (0 Unknown, 1 Digging, 2 MechanicalDigging, 3 Walk, 4 Vehicle, and more) — treat the ISG Swagger definition as authoritative rather than hardcoding a list from a document that may be a version behind. ThreatLevel runs 0–100, where 0 is friend, 50 is unknown, and 100 is hostile; it drives the target's colour and priority downstream in Security Desk.

The practical point for energizer-style hardware: most fence controllers have no per-metre localisation at all. What you get from the field system is "zone 3 is in alarm," not "intruder at 47 metres." Reporting LocationType: 3 — or 2 with a coarse midpoint if you want something on the map — is the honest choice for that class of hardware. The visual precision that makes a fence look good on the map doesn't come from the intrusion payload at all; it comes from the fence geometry you register separately.

That geometry is inbound: GET /api/Configuration/GetFences returns each DeviceId, a Name, and an ordered Points[] array of latitude/longitude — this is what lets Security Center draw the fence line on the map automatically instead of an operator hand-placing it. GET /api/Zone/GetZones and GetZone?zoneId= return zone polygons with "type": "Intrusion". PostFences exists in the contract for future use — don't build against it yet.

Two more outbound calls round this section out: POST /api/v1/Fence/Status changes a fence segment's colour in RSA directly (useful for tamper, offline, or bypass states independent of an active intrusion), and /api/v1/Fence/Intrusions accepts an array for batching multiple simultaneous events in one call.

Gotcha — A fence intrusion whose DeviceId doesn't exactly match one sent in the last heartbeat is accepted with a 2xx and then quietly discarded — this is the same join-key problem from Section 4, and it's worth restating here because it's specifically the failure mode for "targets never appear on the map." Diff your device IDs before touching anything else.

Polling the field system efficiently

Most perimeter hardware exposes either a REST API or a raw Modbus/TCP socket, and the pattern that survives contact with real hardware looks the same regardless of which: a single PeriodicTimer loop, every 2–5 seconds, inside one BackgroundService — not a timer per device. For Modbus-style controllers, issue one bulk read (a single FC3 across the whole register block) and decode per device from that one response, rather than one round trip per device; it's an order of magnitude fewer requests and far less likely to trip the controller's own connection limits.

Compare state, don't report levels: keep the last decoded snapshot in memory and only emit a fence intrusion on a false → true transition of an alarm bit, and only emit a fence-state update on change. Reporting the current level on every tick floods the alarm engine with duplicate alarms for a single physical event. For REST-based field systems, cache the session token the same way you cache the ISG token — refresh ahead of expiry, and re-authenticate once on a 401 before giving up on that poll cycle. And bulkhead the loop: one slow or unresponsive device should never be able to stall the heartbeat, so keep polling and heartbeat sending in separate BackgroundService instances.

C# — FieldSystemPoller (abbreviated)
public sealed class FieldSystemPoller : BackgroundService
{
    private readonly IFieldSystemClient _field;
    private readonly Channel<FenceIntrusion> _outbox;
    private readonly ILogger<FieldSystemPoller> _logger;
    private FenceSnapshot _last = FenceSnapshot.Empty;

    public FieldSystemPoller(IFieldSystemClient field, Channel<FenceIntrusion> outbox,
                              ILogger<FieldSystemPoller> logger)
    {
        _field = field;
        _outbox = outbox;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(3));
        while (await timer.WaitForNextTickAsync(ct))
        {
            try
            {
                var current = await _field.ReadAllRegistersAsync(ct); // one bulk read
                foreach (var device in current.Devices)
                {
                    var wasAlarmed = _last.IsAlarmed(device.Id);
                    if (!wasAlarmed && device.IsAlarmed)
                        await _outbox.Writer.WriteAsync(device.ToFenceIntrusion(), ct);
                }
                _last = current;
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "Poll cycle failed; will retry next tick");
            }
        }
    }
}

Gotcha — Poll interval and alarm dwell time interact directly. If the controller latches an alarm state for 10 seconds and you poll every 2 seconds, edge-detection on the transition saves you from duplicates. Skip the state comparison and report levels instead, and the same physical event becomes five separate alarms in the operator's queue.

The inbound contract and acknowledgement

Two endpoints are mandatory, not optional: GET and POST driver configuration. The reason is easy to miss until it costs a customer their settings: during an image upgrade, ISG pulls the current configuration out of the running container via the GET endpoint, creates the new container from the updated image, and pushes that configuration back in via POST. Skip either endpoint and every upgrade wipes the customer's configuration back to defaults.

Beyond that mandatory pair, ISG expects a broader set that's optional in the sense that a driver will run without them, but expected in the sense that RSA's operator workflow assumes they exist: acknowledge fence intrusion, get device statuses, activate/deactivate a device, camera-tracking association, and a bearer-token endpoint if you chose BearerToken over Basic auth.

C# — inbound controllers (sketch)
[ApiController]
[Route("api/Configuration")]
public sealed class ConfigurationController : ControllerBase
{
    private readonly IDriverConfigStore _store;

    public ConfigurationController(IDriverConfigStore store) => _store = store;

    [HttpGet]
    public async Task<ActionResult<DriverConfig>> Get(CancellationToken ct)
        => Ok(await _store.LoadAsync(ct));

    [HttpPost]
    public async Task<IActionResult> Post(DriverConfig config, CancellationToken ct)
    {
        await _store.SaveAsync(config, ct); // called on every image upgrade
        return NoContent();
    }
}

[ApiController]
[Route("api/Fence")]
public sealed class FenceController : ControllerBase
{
    private readonly IFieldCommandTranslator _translator;

    public FenceController(IFieldCommandTranslator translator) => _translator = translator;

    [HttpPost("Acknowledge/{deviceId}")]
    public async Task<IActionResult> Acknowledge(string deviceId, CancellationToken ct)
    {
        await _translator.AcknowledgeAsync(deviceId, ct);
        return NoContent();
    }

    [HttpGet("Status")]
    public async Task<ActionResult<IEnumerable<DeviceStatus>>> GetStatuses(CancellationToken ct)
        => Ok(await _translator.GetDeviceStatusesAsync(ct));
}

Acknowledgement deserves an honest word rather than a glossed-over one: an acknowledge arriving from ISG has to be translated into an actual vendor command, and if the vendor's command vocabulary is undocumented — which is common for this class of hardware — your real options are to push the vendor for the command set, capture traffic from their own client and reverse the strings, or ship acknowledge as local-only (clearing the alarm in Security Center without touching the physical controller) and document that limitation clearly. Keep whichever choice you make behind one IFieldCommandTranslator interface so it can be filled in later without touching the controller layer.

The configuration web page and its auto-login token live in the same container and controller layer — issue a short-lived signed token when ISG requests the page, validate it on load, and skip the interactive login entirely so the page can be iframed inside Config Tool.

Gotcha — Teams sometimes treat the mandatory config GET/POST pair as a nice-to-have because nothing fails locally without it. It only fails at the worst possible time — a production image upgrade — and by then the original configuration is gone.

Containerising and shipping

Build a multi-stage Dockerfile on mcr.microsoft.com/dotnet/aspnet:8.0, bind Kestrel to 443 with a certificate, and expose that port explicitly:

dockerfile — multi-stage build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish Contoso.FenceDriver.csproj -c Release -o /app

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app .
EXPOSE 443
ENTRYPOINT ["dotnet", "Contoso.FenceDriver.dll"]
C# — Program.cs (DI + Kestrel + Basic auth)
var builder = WebApplication.CreateBuilder(args);

builder.WebHost.ConfigureKestrel(options =>
    options.ListenAnyIP(443, o => o.UseHttps("/certs/driver.pfx", "<redacted>")));

builder.Services.AddSingleton<IGatewayPasswordCipher, GatewayPasswordCipher>();
builder.Services.AddSingleton(sp =>
    GatewayOptions.FromEnvironment(sp.GetRequiredService<IGatewayPasswordCipher>(),
                                    sp.GetRequiredService<ILogger<GatewayOptions>>()));
builder.Services.AddSingleton<IGatewayTokenCache, GatewayTokenCache>();
builder.Services.AddTransient<GatewayAuthHandler>();
builder.Services.AddHttpClient<IGatewayClient, GatewayClient>()
    .AddHttpMessageHandler<GatewayAuthHandler>();
builder.Services.AddHostedService<FieldSystemPoller>();
builder.Services.AddAuthentication("Basic")
    .AddScheme<AuthenticationSchemeOptions, InboundBasicAuthHandler>("Basic", null);

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();

If the deployment forces you onto a Windows container (Nano Server) rather than Linux, plan your debugging strategy around it before you need it: the image has essentially cmd and curl.exe and no sh, powershell, nc, or telnet. Debug from the host, or bake a small diagnostics endpoint into the driver itself — you won't have an interactive shell worth trusting inside the container when something goes wrong.

docker build and push to a registry ISG can reach; ISG pulls and runs the image, maps the ports you declared, and restarts the container on crash. Register the image in Config Tool with its name, tag, and registry URL, then walk the driver-creation wizard where you declare exposed ports — this is the same declaration Section 3 referenced for reading ExposedPorts back at runtime. For a productised driver, Genetec's Azure Container Registry is the standard distribution path, and validation guides exist per device class worth checking before you assume your device class has no special requirements.

Gotcha — A driver that builds and runs fine on your Linux dev machine can still fail silently on a Windows container host purely because your debugging habits (a quick sh into the container to curl an endpoint) don't exist there. Bake in an HTTP diagnostics route early, not after the first incident.

Validation and the RSA side

Most of the pain in this whole project lives in the end-to-end proving sequence, not in the code. Walk it in order: the ISG service is running and the driver container shows Running; the driver then needs to show Connected, which requires the heartbeat URL, API username, and API password to all be simultaneously valid — when a driver never reaches Connected, the usual suspects are an unreachable callback URL, a rejected certificate, wrong credentials, or a wrong ContainerInstance header value. From there, a RabbitMQ output driver needs to be configured against the same RabbitMQ server the RSA plugin role uses; a tracking system is added inside RSA and communication established; and finally, from Security Desk's Maps task, triggering a fence alarm on the physical device should surface a target icon at the correct position, change the impacted segment's colour, and add an entry to the RSA intrusions panel.

One diagnostic here is worth more than the rest of this section combined: a red RabbitMQ status in the UI is not the same thing as a failed connection. If the broker's own logs show zero AMQP connection attempts — not failed attempts, unattempted ones — the driver never reached its connection code at all. Before chasing firewall rules or credentials, check that the failover/connection row you configured actually persisted after saving; a blank password field on that form can silently discard the whole row without an error, which looks identical to a network problem until you go looking specifically for it.

Gotcha — "Red RabbitMQ status" sends most people straight to firewall and credential debugging. Check whether the connection was ever attempted first — the actual cause, more often than not, is a configuration row that didn't save.

Version discipline and closing

Driver packs, plugin builds, and portal downloads default to the latest Security Center version by default. If the deployment target runs an older release, filter explicitly — a newer pack installs without complaint and then misbehaves in ways that read exactly like a device fault, not a version mismatch. The ISG API version gate from Section 4 is the same class of problem wearing different clothes. Licence entitlements are also worth a second look before you conclude an integration broke something it didn't: entitlement pools are often separate — restricted-camera quotas for ONVIF-enrolled cameras are distinct from the standard camera pool, for instance, and a camera sitting in a disabled restricted slot simply won't stream, which has nothing to do with your driver at all.

That's the ISG driver end to end — the two contracts, the device-ID join key, fence versus intrusion modelling, and the validation sequence that actually proves it works. Part 2 covers the Workspace SDK plugin side: MediaPlayer (UI-thread only, marshalled through the workspace dispatcher), ActionManager for recording and video protection, MediaExporter for G64-to-MP4 export, and why live recording can't be redirected to an arbitrary path — the Archiver owns physical storage, and export-on-acknowledgement is the supported pattern instead.

Need an ISG driver or RSA integration built and validated end to end? Xplug.in is a Genetec Technology Partner with 140+ plugins shipped since 2014.

Schedule a technical demo