Hub: create your connector

Training

In the context of building supervision and automation, the solution Immersive Hub plays a central role in:

  • She Centralizes all data from heterogeneous sources (IoT, PLCs, databases, REST APIs, etc.).
  • She Delivers client applications (3D Beholder, dashboards, automation scripts) up-to-date information in real time.
  • It allows your systems to Speak a common language, even if the underlying protocols are different.

The Hub, through its connectors, has a large number of protocols, OPC UA, MQTT, Excel etc.

But what if:

  • Your sensor or your field service is not natively supported by the Hub?
  • You need to connect a Proprietary API or a custom data source ?

This is where creating a custom connector comes in.

A Hub connector acts as a bridge between your data source and the Hub.

  • It Listens or questions External source
  • It transmits values at the Hub
  • It allows Handles recorded by receive up-to-date data.

In this tutorial, we'll :

  1. Set up a concrete example by connecting to a REST API simulating building sensors
  2. Create a connector that can retrieve and expose this data to the Hub.

Prerequisites

  1. Have installed and launched a Hub
  2. Have knowledge of the Hub's role and features.
  3. Knowing how to develop in .Net.

Why create a custom Hub connector?

In the solution Immersive, Hub is the Core of Data Architecture. It acts as a conductor between:

  • Data sources (IoT sensors, PLCs, databases, REST APIs, etc.)
  • Consumers (Beholder 3D, dashboards, automation scripts...)

The Hub already supports Many standard protocols such as:

  • UCI UA for industrial controllers
  • Modbus for technical systems
  • MQTT for IoT flows
  • REST and SQL for traditional services and databases

The typical case: Deployment to a customer with an unsupported source

When deploying Immersive in a End customer, it is not uncommon to find yourself in one of these scenarios:

  1. The client exposes data by a proprietary protocol
    • Example: An elevator manufacturer with a homemade TCP protocol
    • Or an old BMS system that publishes its data to a Non-standard format
  2. The customer has a specific API
    • Example: a internal REST web service exposing temperatures, consumptions or alarms
    • Or a Cloud Data Facade that requires custom authentication
  3. Data access requires business adaptation
    • Filtering, aggregating, or converting units before they are usable
    • Added logic to report only the Supervisory values

In these cases, The Hub cannot, as it stands, query the source directly. Without a dedicated connector, the data remain inaccessible for Beholder or your automation scenarios.

The role of the custom connector

Create a Custom Hub Connector allows you to:

  • Extending the Hub to new protocols without touching the core of the software
  • Tailor access to specific APIs (REST, SOAP, files, WebSocket streams...)
  • Transform or filter data before it arrives in Immersive

In practice:

  • The connector acts as a translator between the customer source and the Hub
  • Every Handle Saved is associated with a path that the connector knows how to interpret
  • The Hub remains generic while your connector handles the specific logic

Presentation of the project

In this example project, we are supporting a Condominium management company ("The Customer" for the future) wishing to use the Immersive platform to create a Digital twin of his building.

The IoT data The information needed for real-time representation is available on the customer's infrastructure, exposed via an internal REST API.

However, this data uses a Specific protocol which is currently not supported by Immersive's existing connectors. This means that it will be necessary to Develop a dedicated connector to ensure the integration and visualization of information in the digital twin.

This step is a key issue in ensuring the Continuity between the customer's IoT infrastructure and the 3D representation and management capabilities offered by Immersive.

Detailed presentation of the project (downloadable)

The project to simulate the REST API of this co-ownership syndicate provided is a Visual Studio project (C# / ASP.NET Core) that replicates building infrastructure and exposes data via a REST API. It serves as a basis for developing a Immersive connector adapted to this protocol not natively supported.

Click here to download the ASP.Net simulation project:

Simulated equipment and sensors

  • Temperature : "Room 101 Temperature" (unit: °C).
  • Humidity : "Room 101 Humidity" (unit: %).
  • Light : "Corridor Light Level" (unit: lux).
  • Presence : "Office Presence" (Boolean).
  • Energy : "Main Electric Counter" (unit: kWh, cumulative).
  • Smoke : "Smoke Detector" (Boolean).
  • Elevator : "Lift A" (multi-variable: floor digital, state enum, door enum).
  • Garage door : "Garage G1" (multi-variable: door enum, obstacle bool).

REST endpoints exposed

GET  /api/Devices           → Liste de tous les équipements (single-value et multi-variables).
GET  /api/Devices/{id:int}  → Détail d’un équipement par identifiant.
POST /api/Scene             → Déclenche une scène (simulation d’action bâtimentaire).

Data model (JSON responses)

Single-value equipment (e.g. "Corridor Light Level"):

{
  "id": 3,
  "name": "Corridor Light Level",
  "type": "light",
  "path": "",
  "value": "300",
  "unit": "lux",
  "timestampUtc": "2025-08-17T10:23:00Z",
  "variables": null
}

This type of equipment exposes only one value via the "value".

Multi-variable equipment – "Lift A" elevator:

{
  "id": 101,
  "name": "Lift A",
  "type": "elevator",
  "path": "/equipments/101",
  "value": null,
  "unit": null,
  "timestampUtc": null,
  "variables": [
    {
      "name": "floor",
      "path": "/equipments/101/floor",
      "kind": "numeric",
      "unit": null,
      "value": "3",
      "timestampUtc": "2025-08-17T10:23:00Z"
    },
    {
      "name": "state",
      "path": "/equipments/101/state",
      "kind": "enum",
      "unit": null,
      "value": "Moving",
      "timestampUtc": "2025-08-17T10:23:00Z"
    },
    {
      "name": "door",
      "path": "/equipments/101/door",
      "kind": "enum",
      "unit": null,
      "value": "Closed",
      "timestampUtc": "2025-08-17T10:23:00Z"
    }
  ]
}

Multi-variable equipment – "Garage G1" garage door:

{
  "id": 201,
  "name": "Garage G1",
  "type": "garageDoor",
  "path": "/equipments/201",
  "value": null,
  "unit": null,
  "timestampUtc": null,
  "variables": [
    {
      "name": "door",
      "path": "/equipments/201/door",
      "kind": "enum",
      "unit": null,
      "value": "Closed",
      "timestampUtc": "2025-08-17T10:23:00Z"
    },
    {
      "name": "obstacle",
      "path": "/equipments/201/obstacle",
      "kind": "bool",
      "unit": null,
      "value": "false",
      "timestampUtc": "2025-08-17T10:23:00Z"
    }
  ]
}

This equipment exposes several variables via the "Variable".

Examples of API calls

List all equipment

GET /api/Devices

Retrieve equipment by id

GET /api/Devices/101

Trigger a scene

Supported scenes: Corridor Light / Corridor light (on/off), Fire Alarm / Fire alarm (on/off).

POST /api/Scene
Content-Type: application/json

{
  "sceneName": "Corridor Light",
  "action": "on"
}

Sample answer:

{
  "scene": "Corridor Light",
  "action": "on",
  "timestamp": "2025-08-17T10:23:00Z",
  "message": "💡 Corridor light on"
}

Now that the presentations with the client's data source have been made, we can start thinking about the implementation of our connector.

Abstraction DLL: Contract to be respected (IConnector) and implementation basis (ConnectorBase)

To develop a connector that is recognized by the Hub, it must Respecting a contract common: it is this contract that allows the Hub to identify the connector, orchestrate its lifecycle and exchange data (handles, values, commands). This contract is defined in the DLL GraphicStream.Immersive.Hub.Abstractions.

In concrete terms, you have two paths:

  • Implement the interface IConnector for full control (you code the entire lifecycle and interactions).
  • Do Inherit the class of your custom connector from ConnectorBase To save time (lifecycle, error handling, scheduling and publishing already supported, you only code the business).

The rest of this chapter presents each member of the contract (IConnector) and explains how ConnectorBase provides the framework to speed up implementation.

Interface IConnector — Members and role

Interface Declaration:

/// <summary>
/// Defines the contract for a Hub connector.
/// </summary>
public interface IConnector
{
    /// <summary>
    /// Gets or sets the Hub context used to interact with the host.
    /// </summary>
    IHubContext? Context { get; set; }

    /// <summary>
    /// Gets or sets the connector display name.
    /// </summary>
    string Name { get; set; }

    /// <summary>
    /// Gets or sets the connector's description.
    /// </summary>
    string? Description { get; set; }

    /// <summary>
    /// Gets or sets the connector's pattern.
    /// </summary>
    string? Pattern { get; set; }

    /// <summary>
    /// Gets or sets the connector's connection string.
    /// </summary>
    string? ConnectionString { get; set; }

    /// <summary>
    /// Gets the <see cref="SessionStatus"/> of the current <see cref="OpcHubConnectorSession"/>
    /// </summary>
    Status Status { get; set; }

    /// <summary>
    /// Gets the connector definition associated to the current <see cref="IConnector"/> instance.
    /// </summary>
    ConnectorDefinition? Definition { get;}

    /// <summary>
    /// Gets the list of associated <see cref="ActivitySign"/>.
    /// </summary>
    System.Collections.Generic.IEnumerable<ActivitySign>? ActivitySigns { get; }

    /// <summary>
    /// Initialize the current <see cref="ConnectorBase"/> with the base useful parameters.
    /// </summary>
    Task Initialize();

    /// <summary>
    /// Connects the connector to its data source.
    /// Returns true if the connection succeeds.
    /// </summary>
    Task<bool> Connect();

    /// <summary>
    /// Disconnects and cleans up the connector.
    /// </summary>
    Task<bool> Disconnect();

    /// <summary>
    /// Return true if the current <see cref="IConnector"/> can handle the specified handle.
    /// </summary>
    /// <param name="handle"></param>
    /// <returns></returns>
    virtual bool CanHandle(IHandle handle) { return true; }

    /// <summary>
    /// Registers handles (paths) that the Hub wants to monitor.
    /// Returns true if at least one handle is supported.
    /// </summary>
    System.Collections.Generic.List<Handles.HandleRegistrationResponse> HandleHandles(IEnumerable<IHandle> handles);

    /// <summary>
    /// Requests the connector to write a value to the specified handle (output direction).
    /// </summary>
    /// <param name="handle">The handle representing the target data path.</param>
    /// <param name="value">The value to write to the target.</param>
    /// <returns>
    /// A task that completes with <c>true</c> if the write was successful, or <c>false</c> otherwise.
    /// </returns>
    Task<WriteValueResult> WriteValue(string path, string? value);

}

Detailed description of each field/property/method

  • Context: IHubContext?

Allows you to interact with the Hub (log, notify a handle change, save subscriptions, access storage, etc.).

  • Name : string
    • The display name of the connector (readable on the UI/ops side).
    • Useful for distinguishing between multiple instances of the same connector type.
    • The name can be set by the connector or loaded from the Hub configuration.
  • Description: string?
    • Free description (purpose, scope, target environment).
    • Optional.
    • The description can be set by the connector or loaded from the Hub configuration.
  • Pattern: string?
    • Connector-specific pattern/filter string (e.g. path mask, node selection, etc.). The path taken into account by the connector can be based on a match with the pattern. This tutorial shows an example.
    • Optional; business interpretation by the connector.
    • The pattern can be set by the connector or loaded from the Hub configuration.
  • ConnectionString: string?
    • Source connection parameters (URL, credentials, database, broker, etc.).
    • The semantics depend on the protocol/source supported by the connector.
    • The connection string can be set by the connector or loaded from the Hub configuration.
  • Status: Status
    • Current Connector Status (ENUM). Typical values: Disconnected, Connecting, Connected, ConnectError, DisconnectedWithCommunicationError, WorkingError.
    • To be updated during transitions (From Connect(), during network errors, etc.).
    • The status is used to alert Hub administrators or monitoring tools to the viability of the data source represented by the connector.
  • Definition: ConnectorDefinition?
    • Structured definition/config associated with the connector (metadata, declarative properties).
    • Provided by the Hub configuration for this instance.
    • Configuration is a way to flexibly set up a connector.
  • ActivitySigns: IEnumerable<ActivitySign>?
    • Activity signals/indicators attached to the connector (e.g., polling activity, throughput, etc.).
    • Optional; used by Immersive observability.
    • Signs of activity are used to alert Hub administrators or monitoring tools to the viability of the data source represented by the connector.
  • Initialize() : Task
    • "Cold" initialization: validate fields (ConnectionString, Pattern, etc.), create clients (HTTP, MQTT, etc.), prepare mappers and caches.
    • Must be idempotent and throw clear exceptions if the configuration is invalid.
  • Connect() : Task<bool>
    • Effective connection to the source (establish session, handshake, open subscriptions).
    • Returns true If the connection is successful. Updates Status consequently.
  • Disconnect() : Task<bool>
    • Clean closure of the connection (unsubscribe, flush, disposes of resources).
    • Returns true if the disconnection went well.
  • CanHandle(IHandle handle): bool
    • Indicates whether the connector can handle a given handle.
    • To be overridden in a concrete implementation if the connector supports only a subset of paths.
  • HandleHandles(IEnumerable<IHandle> handles) : List<HandleRegistrationResponse>
    • Saves a list of handles requested by the Hub (paths to be monitored/published) on the connector has validated via CanHandle.
    • Returns, for each handle, a record response (HandleRegistrationResponse) indicating the effective support and the final key/path used.
  • WriteValue(string path, string? value) : Task<WriteValueResult>
    • Writing (outbound) a value to the source on a path (target handle).
    • Returns a WriteValueResult indicating success/failure and, depending on the type, additional details.

Class ConnectorBase — Basis of implementation

The class ConnectorBase is provided in the DLL GraphicStream.Immersive.Hub. It is already implementing the majority of the contract IConnector and provides a standard framework: Management of the Status, paths (HandledPaths), status messages (StateMessages), Integration with the IHubContext and the IConnectorsManager.

By inheriting ConnectorBase, you just have to override some virtual methods to plug in your protocol logic:

  • OnConnect() and OnDisconnect() : To establish or close the connection to the data source.
  • OnCanHandle(IHandle handle) : To set the criteria for supporting a handle (default, based on Pattern).
  • GetHandle(string path, DateTime? date) : To return the value of a targeted handle.
  • HandleHandles(IEnumerable<IHandle>) : To handle the registration of new handles requested by the Hub.
  • WriteValue(string path, string? value) —To write a value to the data source.

Formatting Paths & Using a \Pattern\ (RegExp)

The Hub can run Multiple connectors in parallel. When a session offers a Handle, the Hub calls the CanHandle of each connector and retains the First who answers true. By default, this decision is based on two elements: The state connector (IConnector.Status == Connected) and the Field IConnector.Pattern that is assessed as a regular expression on the Path of the handle.

To avoid ambiguity between connectors, the paths exposed by the handles must allow connectors to be able to say via CanHandle whether the data targeted by the handle can be supported by them or not.

For example, Handles targeting data hosted by an OPC UA server will have paths such as:

  • nsu=namespaceUri; i=integer<
  • nsu=namespaceUri; s=string
  • nsu=namespaceUri; g=guid
  • nsu=namespaceUri; b=base64string

For example:

ns=4;i=2

Our connector for our Condominium Trustee learning project must recognize in a way that unequivocal the paths that him belong. So let's start with the following standardization:

BuildingSensor://{deviceIdentifier}/{VariableName}
  • BuildingSensor:// : Paths to the syndicate's data should start with this scheme.
  • deviceIdentifier : Numeric or alphanumeric identifier of the simulated equipment.
  • VariableName : variable name (optional) for multivariate devices (e.g. floor, door, state…).

How to get the path recognized?

We could simply in OnCanHandle of ConnectorBase Simply check the scheme via Uri.Scheme == "buildingsensor": It works and is sufficient. To take the example further and continue our learning, let's add precision/filtering. So we're going to:

  • Define a Pattern (regexp) that captures our path format.
  • Adapt OnCanHandle to build on this pattern and the state of the connector.

The recommended pattern

This pattern accepts the "BuildingSensor://" scheme, an equipment identifier, and an optional variable name:

^buildingsensor:\/\/(?<device>[A-Za-z0-9_-]+)(?:\/(?<prop>[A-Za-z0-9_.:-]+))?$
  • (?<device>...) Captures the device ID.
  • (?<prop>...)? captures the variable if present (optional).
  • Schema case is handled via the IgnoreCase.

Step-by-step creation of the connector to BuildingSensorSimulator

This guide details each step to properly develop your connector. All key methods are shown as implemented.

Click here to download the final ASP.Net and ready-to-compile project.

Step 1: Create the Visual Studio project

  1. Open Visual Studio > Class Library (.NET).
  2. Name the project: MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator.
  3. Target: .NET 9.0 (aligned with the Hub).

Step 2: Add the DLL via NuGet

Instead of a ProjectReference, install the Abstraction DLLs for a NuGet package :

PM> Install-Package GraphicStream.Immersive.Hub.Abstractions
# ou en CLI :
dotnet add package GraphicStream.Immersive.Hub.Abstractions

This results in the Hub contracts (IConnector, ConnectorBase, IHandle, HandleSnapshot, WriteValueResult, etc.).

Step 3: Create the connector class

Create a class and name it BuildingSensorSimulatorConnector. Inherit from ConnectorBase to benefit from the life cycle and plumbing. Builder and main fields:

public sealed class BuildingSensorSimulatorConnector : ConnectorBase, IDisposable
{
    private readonly Uri _baseUri;
    private readonly TimeSpan _pollInterval;
    private readonly HttpClient _httpClient;
    private readonly JsonSerializerOptions _jsonOptions;
    private CancellationTokenSource? _internalCts;
    private Task? _runLoop;
    private bool _disposed;
    private readonly System.Threading.Lock _interestGate = new();
    private const string SingleValueProperty = "value";
    private readonly ConcurrentDictionary<string, VariableSnapshot> _pathToLastSnapshot = new();

    public BuildingSensorSimulatorConnector()
    {
        this._baseUri = new Uri("https://localhost:7003");
        this._pollInterval = TimeSpan.FromSeconds(2);
        this._httpClient = new HttpClient();
        this._jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
        this.Name = "BuildingSensorSimulatorConnector";
    }
}

We define, in hard, the name to be given to the connector, the basic address of the simulation of the Syndicate of Condominium Trustee's server and a value search for a thread that remains to be written every 2 seconds.

Step 4: Define the path schema and class Path

Tracked handles use the schema BuildingSensor://{deviceIdentifier}/{VariableName}. Let's write the class Path which will extract cleanly DeviceIdentifier and Property (variable name) from a IHandle :

/// <summary>
/// <para>BuildingSensor://{deviceIdentifier}/{VariableName}</para>
/// </summary>
/// <param name="deviceId">Device identifier.</param>
/// <param name="property">Property name.</param>
public class Path(string deviceId, string property)
{

    /// <summary>
    /// Gets or sets the device identifier associated to the current <see cref="Path Instance" />.
    /// </summary>
    public string DeviceIdentifier { get; set; } = deviceId;

    /// <summary>
    /// Gets or sets the property name associated to the current <see cref="Path.Property" />.
    /// </summary>
    public string Property { get; set; } = property;

    /// <summary>
    /// Extract a formated path from an <see cref="IHandle" /> instance.
    /// </summary>
    /// <param name="h"><see cref="IHandle" /> instance used to gets a formated path.</param>
    /// <returns>A formated path from the specified <see cref="IHandle" /> instance.</returns>
    public static Path FromHandle(IHandle h)
    {
        System.Uri uri = new(h.Path);
        return new(uri.Host, uri.LocalPath.Trim('/'));
    }

Step 5: Recognize the right paths

We then make sure that the connector only handles the schema buildingsensor via OnCanHandle

There are two possibilities:

Step 5.a: Quick and easy recognition

A OnCanHandle simple could be to check that PATH is populated, then that it is a viable URL and finally that it starts with buildingsensor:// :

public override bool OnCanHandle(IHandle handle)
{
    if (string.IsNullOrWhiteSpace(handle.Path)) return false;
    if (!Uri.TryCreate(handle.Path, UriKind.Absolute, out var uri)) return false;
    return uri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase);
}

This method is quick and simple, but allows paths that are not necessarily formatted to extract the device ID and variable through, so the following path would be valid:

buildingsensor://iam_#not(_a_valide_path)

Step 5.b: Accurate recognition

Here we could rely on a regular expression, stored in the Pattern of the IConnector

Step 1) Define the Pattern (e.g. in the builder or via the Hub configuration):

/// <summary>
/// Initializes a new instance of the <see cref="BuildingSensorSimulatorConnector"/> class.
/// </summary>
public BuildingSensorSimulatorConnector()
{
    this._pathToHandle = [];
    this._baseUri = new Uri("https://localhost:7003");
    this._pollInterval = TimeSpan.FromSeconds(2);
    this._httpClient = new HttpClient();
    this._jsonOptions = new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    };

    this.Name = "BuildingSensorSimulatorConnector";
    this.Pattern = @@"^buildingsensor://(?<device>[A-Za-z0-9_-]+)(?:/(?<prop>[A-Za-z0-9_.:-]+))?$";
}

Step 2) Create or Replace the Method OnCanHandle by a state-based and regexp-based version:

/// <inheritdoc />
public override bool OnCanHandle(IHandle handle)
{
    // 1) Le connecteur doit être connecté pour accepter un handle.
    if (this.Status != Status.Connected)
        return false;

    // 2) Path requis.
    var path = handle?.Path;
    if (string.IsNullOrWhiteSpace(path))
        return false;

    // 3) S'assurer que Pattern est défini (fallback si oublié dans la config).
    if (string.IsNullOrWhiteSpace(this.Pattern))
        this.Pattern = @@"^buildingsensor:\/\/(?<device>[A-Za-z0-9_-]+)(?:\/(?<prop>[A-Za-z0-9_.:-]+))?$";

    // 4) Évaluer la regexp (Regexp du Hub) sur le Path.
    return System.Text.RegularExpressions.Regex.IsMatch(
        path,
        this.Pattern,
        System.Text.RegularExpressions.RegexOptions.IgnoreCase |
        System.Text.RegularExpressions.RegexOptions.CultureInvariant
    );
}

Step 6: Save the requested handles

HandleHandles parse the paths, validate the diagram, memorize the pairs DeviceId/Property and returns a record response for each:

/// <inheritdoc/>
public override List<HandleRegistrationResponse> HandleHandles(IEnumerable<IHandle> handles)
{
    var responses = new System.Collections.Generic.List<HandleRegistrationResponse>();

    // Make a temporary storage of asked id/names extracted from handles's path.
    var temporaryIdToNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);

    foreach (var h in handles)
    {
        var resp = HandleRegistrationResponse.FromHandle(h);
        try
        {
            // check path.
            if (!Uri.TryCreate(h.Path, UriKind.Absolute, out var uri) ||
                !uri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase))
            {
                resp.SupportStatus = SupportStatus.Unknown;
                resp.Message = "Unsupported scheme (expected BuildingSensor://...)";
            }
            else
            {
                var p = MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator.Path.FromHandle(h);
                var deviceIdentifier = p.DeviceIdentifier?.Trim();
                var propertyName = string.IsNullOrWhiteSpace(p.Property) ? SingleValueProperty : p.Property.Trim();

                if (string.IsNullOrEmpty(deviceIdentifier))
                {
                    resp.SupportStatus = SupportStatus.Unknown;
                    resp.Message = "Missing device identifier in path.";
                }
                else
                {
                    if (!temporaryIdToNames.TryGetValue(deviceIdentifier, out var set))
                        temporaryIdToNames[deviceIdentifier] = set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

                    set.Add(propertyName);
                    resp.SupportStatus = SupportStatus.Known;
                    resp.Message = $"Registered {deviceIdentifier}/{propertyName}.";
                }
            }
        }
        catch (Exception ex)
        {
            resp.SupportStatus = SupportStatus.Failed;
            resp.Message = $"Registration parsing error: {ex.Message}";
        }

        responses.Add(resp);
    }

    // merge what was registered here with previoulsy known
    lock (_interestGate)
    {
        foreach (var kv in temporaryIdToNames)
        {
            if (!_deviceIdentifierToVariableNames.TryGetValue(kv.Key, out var set))
            {
                set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
                _deviceIdentifierToVariableNames[kv.Key] = set;
            }

            foreach (var prop in kv.Value)
                set.Add(prop); // no double occurences
        }
    }

    // Report registration
    Context?.AddStateMessage(LogMessage.Info($"Registered interests: {string.Join(", ", temporaryIdToNames.Select(kv => $"{kv.Key}/{string.Join("|", kv.Value)}"))}"));

    return responses;
}

For each IHandle received we make sure to send back a HandleRegistrationResponse with something to say to the Hub that calls this function if the IHandle has been analyzed, recognized and that it is viable.'

To facilitate subsequent processing, we safeguard the associations Device ID and Variable names.

Step 7: Start/Stop Polling

OnConnect() Starts the loop; Stop() and Dispose() Properly adopt:

public override Task<bool> OnConnect()
{
    RunAsync(CancellationToken.None);
    return Task<bool>.FromResult(true);
}

public Task RunAsync(CancellationToken cancellationToken)
{
    ObjectDisposedException.ThrowIf(this._disposed, this);
    if (this._runLoop != null) throw new InvalidOperationException("The connector is already running.");

    this._internalCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
    this._runLoop = Task.Run(() => RunLoopAsync(_internalCts.Token), CancellationToken.None);
    return this._runLoop;
}

public void Stop()
{
    try { _internalCts?.Cancel(); } catch { /* ignore */ }
}

public override void Dispose()
{
    if (this._disposed) return;
    this._disposed = true;
    Stop();
    try { _runLoop?.Wait(TimeSpan.FromSeconds(2)); } catch { /* ignore */ }
    this._internalCts?.Dispose();
    this._httpClient.Dispose();
    base.Dispose();
}

Step 8: Polling Loop & Sensor Reading

The loop calls periodically /api/Devices then processes the snapshot:

/// <summary>
/// Main polling loop: fetches sensors and emits changes/logs.
/// </summary>
private async Task RunLoopAsync(CancellationToken ct)
{
    // Gate partagé (singleton ou service)
    var gate = new DistinctLogGate(reminderInterval: TimeSpan.FromMinutes(1));
    this.AddStateMessage($"Connecting to simulator at {_baseUri}");

    while (!ct.IsCancellationRequested)
    {
        try
        {
            gate.TryLogConnectorDistinct(this,"GettingList", LogMessage.Info("Getting list of sensors and device."));

            var sensors = await GetSensorsAsync(ct).ConfigureAwait(false);

            gate.TryLogConnectorDistinct( this, "ListGetted", LogMessage.Info($"{sensors.Count} getted"));

            ProcessSnapshot(sensors);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            // Graceful shutdown.
            break;
        }
        catch (Exception ex)
        {
            this.AddErrorStateMessage($"Polling failed: {ex.Message}", ex.Message);
        }

        try
        {
            await Task.Delay(_pollInterval, ct).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            break;
        }
    }

    this.AddStateMessage("Connector stopped.");
}


private async Task<List<DeviceDto>> GetSensorsAsync(CancellationToken ct)
{
    var uri = new Uri(_baseUri, "/api/Devices");
    using var resp = await _httpClient.GetAsync(uri, ct).ConfigureAwait(false);
    resp.EnsureSuccessStatusCode();
    await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
    var data = await JsonSerializer.DeserializeAsync<List<DeviceDto>>(stream, _jsonOptions, ct).ConfigureAwait(false);
    return data ?? [];
}

Step 9: Process the snapshot & publish only the changes

This method will analyze the recovered devices and their values and compare them to what the connector already has via its dictionaries. If a piece of data is considered to be changed, it must be sent back to the Hub to come to the sessions that are interested.

private void ProcessSnapshot(List<DeviceDto> devices)
{
    Dictionary<string, HashSet<string>> interests;
    lock (_interestGate)
    {
        interests = _deviceIdentifierToVariableNames.ToDictionary(
            kv => kv.Key,
            kv => new HashSet<string>(kv.Value, StringComparer.OrdinalIgnoreCase),
            StringComparer.OrdinalIgnoreCase);
    }
    if (interests.Count == 0) return;

    foreach (var d in devices)
    {
        var deviceKey = d.Id.ToString(CultureInfo.InvariantCulture);
        if (!interests.TryGetValue(deviceKey, out var wantedProps)) continue;

        if (d.Variables is { Count: > 0 })
        {
            foreach (var v in d.Variables)
            {
                if (!wantedProps.Contains(v.Name)) continue;
                var path = $"BuildingSensor://{deviceKey}/{v.Name}";
                var value = v.Value ?? string.Empty;
                var ts = v.TimestampUtc;
                PublishIfChanged(path, value, ts, v.Unit, HandleStatus.Good);
            }
        }
        else
        {
            if (!wantedProps.Contains(SingleValueProperty)) continue;
            var path = $"BuildingSensor://{deviceKey}/{SingleValueProperty}";
            var value = d.Value?.ToString(CultureInfo.InvariantCulture) ?? "";
            var ts = d.TimestampUtc;
            var unit = d.Unit;
            PublishIfChanged(path, value, ts!.Value, unit, HandleStatus.Good);
        }
    }
}

private void PublishIfChanged(string path, string value, DateTime changeDate, string? unit, HandleStatus status)
{
    var snap = new VariableSnapshot(value, changeDate);
    if (this._pathToLastSnapshot.TryGetValue(path, out var prev))
    {
        if (prev.Value == snap.Value && prev.TimestampUtc == snap.TimestampUtc) return;
    }
    this._pathToLastSnapshot[path] = snap;

    HandleSnapshot snapshot = new(
        path,
        value,
        status,
        changeDate,
        DateTime.UtcNow,
        unit,
        null);
    Context?.NotifyHandleChanged(snapshot);

    (HandleChanges as SimpleSubject<HandleSnapshot>)!.OnNext(snapshot);
}

The method PublishIfChanged will compare the last known value of the path to the value retrieved from the call to the simulation project.

If a change is detected, a HandleSnapshot is instantiated and the value is notified to the Hub to notify it that the current connector has detected a change in value or state.'

The Hub internally will determine the different Sessions who have shown an interest in this path and pass on to them the state/value of this handle.'

Step 10: Writes → mapping to scenes

The method WriteValue allows the Hub to contact the connector to ask it to write a value for the specified path. It is up to the Hub to contact its data source and write the data to the location targeted by the Path. :

/// <inheritdoc/>
public override async Task<WriteValueResult> WriteValue(string path, string? value)
{
    // default result.
    var result = new WriteValueResult { IsSuccessFull = false };

    try
    {
        // 1) Parse path BuildingSensor://{deviceId}/{property}
        if (!Uri.TryCreate(path, UriKind.Absolute, out var bsUri) ||
            !bsUri.Scheme.Equals("buildingsensor", StringComparison.OrdinalIgnoreCase))
        {
            result.ExceptionMessage = "Unsupported path scheme (expected BuildingSensor://).";
            return result;
        }

        var deviceIdentifier = bsUri.Host;                      // ex: "101"
        var propertyName = bsUri.LocalPath.Trim('/');       // ex: "value", "door", "state", ...

        if (string.IsNullOrWhiteSpace(deviceIdentifier))
        {
            result.ExceptionMessage = "Missing device identifier in path.";
            return result;
        }

        // 2) gets the device to get its Type
        //    GET /api/Devices/{id}
        var deviceUri = new Uri(_baseUri, $"/api/Devices/{deviceIdentifier}");
        using var devResp = await _httpClient.GetAsync(deviceUri).ConfigureAwait(false);
        if (!devResp.IsSuccessStatusCode)
        {
            string message = $"Device {deviceIdentifier} not found (HTTP {(int)devResp.StatusCode}).";
            this.AddErrorStateMessage(message, null);
            result.ExceptionMessage = message;
            return result;
        }

        var devStream = await devResp.Content.ReadAsStreamAsync().ConfigureAwait(false);
        var device = await JsonSerializer.DeserializeAsync<DeviceDto>(devStream, _jsonOptions).ConfigureAwait(false);
        if (device is null)
        {
            result.ExceptionMessage = "Failed to deserialize device payload.";
            this.AddErrorStateMessage(result.ExceptionMessage, null);
            return result;
        }

        // 3) Compute scene + action
        //    - Type "light"  => SceneName = "Corridor Light", action on/off
        //    - Type "smoke"  => SceneName = "Fire Alarm",     action on/off
        //    - etc can extend here others devices
        string? sceneName = null;
        string? action = null;

        // Helper: transform value into bool (on/off)
        static bool IsTruthy(string? s)
        {
            if (string.IsNullOrWhiteSpace(s)) return false;
            s = s.Trim();
            if (bool.TryParse(s, out var b)) return b;
            if (double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) return d > 0;
            return s.Equals("on", StringComparison.OrdinalIgnoreCase) ||
                    s.Equals("open", StringComparison.OrdinalIgnoreCase) ||
                    s.Equals("start", StringComparison.OrdinalIgnoreCase);
        }

        var truthy = IsTruthy(value);

        switch ((device.Type ?? "").ToLowerInvariant())
        {
            case "light":
                // "Corridor Light"
                sceneName = "Corridor Light";
                action = truthy ? "on" : "off";
                break;

            case "smoke":
                // "Fire Alarm"
                sceneName = "Fire Alarm";
                action = truthy ? "on" : "off";
                break;

            default:
                // Non supporté par le SceneController actuel
                result.ExceptionMessage = $"No scene mapping for device type '{device.Type}' (path: {path}).";
                this.AddErrorStateMessage(result.ExceptionMessage, null);
                return result;
        }

        // 4) Appel du SceneController : POST /api/Scene  { SceneName, Action }
        var sceneUri = new Uri(_baseUri, "/api/Scene");
        var payload = new { SceneName = sceneName, Action = action };
        var json = JsonSerializer.Serialize(payload, _jsonOptions);
        using var content = new StringContent(json, Encoding.UTF8, "application/json");

        using var resp = await _httpClient.PostAsync(sceneUri, content).ConfigureAwait(false);
        if (!resp.IsSuccessStatusCode)
        {
            var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
            result.ExceptionMessage = $"Scene POST failed HTTP {(int)resp.StatusCode}: {body}";
            this.AddErrorStateMessage(result.ExceptionMessage, null);
            return result;
        }

        // 5) Succès
        result.IsSuccessFull = true;
        result.ReturnData = [sceneName, action];
        return result;
    }
    catch (Exception ex)
    {
        result.ExceptionMessage = ex.Message;
        this.AddErrorStateMessage(result.ExceptionMessage, null);
        return result;
    }
}

This method is based on the possibilities offered by the simulation REST server to send an order via a POST request after determining the device targeted by the path provided in the signature of the method.

It initializes and populates an instance of type WriteValueResult to send the result of this write back to the Hub.

Step 11: DTOs & Data Structure

DTOs reflect the simulator payload (single-value & multi-variable):

private sealed class DeviceDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Type { get; set; } = string.Empty;

    public string? Value { get; set; }
    public string? Unit { get; set; }
    public DateTime? TimestampUtc { get; set; }

    public List<DeviceVariableDto>? Variables { get; set; }
}

public sealed class DeviceVariableDto
{
    public string Name { get; set; } = string.Empty;
    public string Path { get; set; } = string.Empty;
    public string Kind { get; set; } = string.Empty;
    public string? Unit { get; set; }
    public string Value { get; set; } = string.Empty;
    public DateTime TimestampUtc { get; set; }
}

Downloading sources