Hub: create your connector
- Getting started
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 :
- Set up a concrete example by connecting to a REST API simulating building sensors
- Create a connector that can retrieve and expose this data to the Hub.
Prerequisites
- Have installed and launched a Hub
- Have knowledge of the Hub's role and features.
- 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:
- 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
- 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
- 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.
The development of a tailor-made connector is essential to ensure compatibility between the customer's IoT protocol and the Immersive platform.
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:
- 📦 BuildingSensorSimulator — v1.0 • 1.8 MB • ZIP
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"
}
The Immersive connector to be developed will have to query /api/Devices (polling or webhook if suitable), Map each piece of equipment and/or variable to an element of the twin (area, model, etc.), then Update the values displayed and the statuses (elevator, garage door, etc.). The scenes exhibited by /api/Scene allow the end-to-end chain to be tested.
The project is provided in Visual Studio : Launch it and then consume the above endpoints directly from your connector. You will be able to publish the archive for download in this tutorial.
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
IConnectorfor full control (you code the entire lifecycle and interactions). - Do Inherit the class of your custom connector from
ConnectorBaseTo 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.
In summary: install the DLL GraphicStream.Immersive.Hub.Abstractions, and then either you implement IConnector, or you derive from ConnectorBase to benefit from a robust and standardized base.
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.).
In Immersive, the IHubContext is injected as a Service through the ASP.NET Core Dependency Injection container.
This means you don't need to instantiate it yourself: the engine automatically injects it into any service or actor that is registered and managed by the ASP.NET Core pipeline.
Specifically, if you declare a dependency of type IHubContext in the constructor of a service or component (e.g. a BackgroundService, a Controller, a HostedService or any other type added to the container), the active instance of the Hub will be provided to you automatically.
⚠️ Attention: add IHubContext in the constructor of an arbitrary class not handled by ASP.NET Core will not work — injection is only done for types registered in the process container.
- 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.
- Current Connector Status (ENUM). Typical values:
- 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
trueIf the connection is successful. UpdatesStatusconsequently.
- Disconnect() : Task<bool>
- Clean closure of the connection (unsubscribe, flush, disposes of resources).
- Returns
trueif 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.
- Saves a list of handles requested by the Hub (paths to be monitored/published) on the connector has validated via
- WriteValue(string path, string? value) : Task<WriteValueResult>
- Writing (outbound) a value to the source on a path (target handle).
- Returns a
WriteValueResultindicating success/failure and, depending on the type, additional details.
To notify the Hub of a change in value, the connector creates an instance of HandleSnapshot and calls Context.NotifyHandleChanged(...). For subscriptions, use Context.RegisterForChange(session, handleRequests). Also think about enriching StateMessages via Context.AddStateMessage(...) for clear observability.
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()andOnDisconnect(): To establish or close the connection to the data source.OnCanHandle(IHandle handle): To set the criteria for supporting a handle (default, based onPattern).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.
In practice: you derive from ConnectorBase, you configure your properties
(Name, Description, Pattern, ConnectionString…),
and you override only the virtual methods that match your protocol.
All lifecycle management (status, errors, status messages) is already provided by the database.
In practice: define ConnectionString and Pattern, implement the true connection to the source targeted by the connector in OnConnect(), filter what you support in CanHandle(), you can rest for that on the spot Pattern, save your paths via HandleHandles(), publish the changes with Context.NotifyHandleChanged(...) and implement the entries via WriteValue(...). Status must accurately reflect the health of the connector.
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…).
Example of valid paths:
BuildingSensor://101/floor: Path to the floor variable of the elevator ID 101.BuildingSensor://201/door: Path to the variable door of the G1 garage door with identifier 201.BuildingSensor://3orBuildingSensor://3/Value: Path to the Value value in Lux of the brightness of the hallway lamp.
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
OnCanHandleto 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.
- 🔌 Connector (BuildingSensor) — v1.0 • 8 KB • ZIP
Step 1: Create the Visual Studio project
- Open Visual Studio > Class Library (.NET).
- Name the project:
MyCompany.Hub.Connectors.ConnectorToBuildingSensorSimulator. - 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
);
}
The result: only a path in the format BuildingSensor://{device}/{prop?} will be accepted and only when the connector is in state Connected. This prevents collisions with other connectors, while taking advantage of the standard behavior of the Hub (pattern + status).
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.
Note the use of logs at the end of HandleHandles :
// Report registration
Context?.AddStateMessage(LogMessage.Info($"Registered interests: {string.Join(", ", temporaryIdToNames.Select(kv => $"{kv.Key}/{string.Join("|", kv.Value)}"))}"));
We access the Hub context to call the AddStateMessage. This way we can more easily track the activities of the Connector created and the problems it may encounter.
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 ?? [];
}
Note the use of intelligent logs:
gate.TryLogConnectorDistinct(this,"GettingList", LogMessage.Info("Getting list of sensors and device."));
gate is an object of type DistinctLogGate instantiated just above (prefer to create global instances in your projects).
gate will analyze the logs emitted by the current Connector and rather than spamming the users with repetitive logs it will only emit the linked log after a specific period of time (here 1 minute) or if a data from the LogMessage has evolved since the last failed log attempt.
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
- 📦 BuildingSensorSimulator — v1.0 • 1.8 MB • ZIP
- 🔌 Connector (BuildingSensor) — v1.0 • 8 KB • ZIP