Atlas API - Subscribing to Live Values
Live values leave Atlas through the value hub, a SignalR hub.
Overview#
Live values leave Atlas through the value hub, a SignalR hub. This is the supported way to receive values as they change, and it is how Bridge and Explorer read from Atlas.
Two things are worth stating before anything else:
- It is a streaming API, not a callback API. Every subscription method returns a stream. You call
StreamAsyncand iterate. You do not register a handler withconnection.On(...), and if you try, nothing will ever arrive. - The same hub also serves history. Four
GetRange*methods return stored ranges over the same connection, so you do not need a separate REST call to backfill a chart before going live. - It also describes a type's shape.
SubscribeToTypeDescriptionstreams a type's columns, endpoints or enum roster, so you can learn what a rowset will contain before you subscribe to any value.
This article covers connecting, the nine methods, what you receive, and the traps.
Connect#
The branch and domain are pinned by the URL, not passed as arguments. One connection serves exactly one world:
/hubs/v1/branches/{branch}/domains/{domain}/subscribe
So a client following two domains opens two connections.
Authentication#
The hub accepts either a session cookie or a bearer token, under the HubAccess policy. A machine
client attaches the bearer through SignalR's access-token provider rather than a header, because the
WebSocket handshake does not carry your custom headers:
var connection = new HubConnectionBuilder()
.WithUrl("https://atlas.example.com:4552/hubs/v1/branches/main/domains/PlantModel/subscribe",
options => options.AccessTokenProvider = async () => await tokenProvider.GetTokenAsync())
.AddMessagePackProtocol()
.Build();
await connection.StartAsync();
Two rules that cause most failed handshakes:
- A failed handshake returns a plain
401, never a redirect. Atlas overrides the cookie handler's redirect-to-login for exactly this reason: a negotiate or WebSocket handshake cannot follow a302. If you get HTML back, you are talking to the wrong plane. - The audience rule applies. The calling client's Pulse registration must list Atlas's
Pulse:Auth:ClientId(defaultAtlas) among its audiences, or the token is rejected even though it is valid. See Registering Timebase apps as OAuth clients in Pulse.
Protocol#
Atlas registers the MessagePack protocol on its hubs, and the value payloads are designed for it:
the DTO uses positional [Key(n)] members, and Guid values ride the wire as a 16-byte binary through
MessagePack's native GUID resolver rather than as a 36-character string.
Use .AddMessagePackProtocol() in a .NET client, as above. Atlas does not raise its receive-message size
limit against you, so large rowsets are not truncated.
On non-.NET clients: the shipped clients are .NET and use MessagePack. If you are connecting from JavaScript or Python, expect to do protocol work, and note that the
[Key(n)]layout means the MessagePack form is a positional array rather than a named map.
The four value subscription methods#
These are the value half of the hub. The fifth method, which describes a type rather than streaming its values, is covered in Describing a type before you subscribe.
Which one you call determines what you are following. These map one-to-one onto Bridge's
SubscriptionMode, so if you have configured Bridge, the shapes will be familiar.
| Method | Follows | Arguments |
|---|---|---|
SubscribeToInstanceProperty |
one property on one instance | string instanceUri, string propertyUri, bool includeWatermarks |
SubscribeToInstance |
every property of one instance | string instanceUri, bool emitFullRow, bool includeWatermarks |
SubscribeToInstancePropertyOfType |
one property across every instance of a type | string typeUri, string propertyUri, bool includeWatermarks |
SubscribeToInstanceOfType |
every property of every instance of a type | string typeUri, bool emitFullRow, bool includeWatermarks |
Every one of them yields an initial rowset carrying the current value first, then one rowset per change. You do not need a separate priming read to populate a display.
Streaming, not callbacks#
await foreach (var rowSet in connection.StreamAsync<ValueRowSetDto>(
"SubscribeToInstance",
"urn:demo:F-101",
true, // emitFullRow
false, // includeWatermarks
cancellationToken))
{
Render(rowSet);
}
Cancelling the token ends the subscription. There is no explicit unsubscribe call: you stop by cancelling or by disposing the connection.
emitFullRow#
truesends every property on each change. Simplest to consume, more bytes.falseswitches the stream to its delta variant, where only the changed columns carry a value. The rowset'sPresentmask says which columns those are: an entry offalsemeans the emit carries nothing for that column, and its cell is shipped as null so that a consumer which ignores the mask still reads a null rather than a stale value.
A null cell is a null in both variants. It never means "unchanged": a column that changed to null in
delta mode is Present[c] = true with the cell's null bit set, which is how you tell it from an
unchanged column. Choose true unless volume is a real problem. Delta mode is where a consumer that
reads nulls without the mask overwrites good stored values, which is the case Bridge's SQL plugins
cover by honouring the mask.
includeWatermarks#
When true, rowsets carry a populated Watermarks array, and watermark-only advances arrive as
null-cell rowsets. That last part matters: with watermarks on, you will receive rowsets that contain no
values at all. They are progress signals, not data, and a consumer that assumes every rowset carries a
value will misread them.
What you receive#
ValueRowSetDto has six members:
| Member | Type | Meaning |
|---|---|---|
InstanceUris |
string[]? |
One entry per row, for the type-scoped subscriptions. Null when the subscription already pins a single instance. |
Rows |
CompoundRowSet |
The values themselves, column-oriented. |
Watermarks |
long[]? |
Populated only when you subscribed with includeWatermarks. |
T |
long |
The rowset's server timestamp. |
Anchors |
long[]? |
The time-axis coordinate per row, where the property has one. Null for atomic values. |
Present |
bool[]? |
One entry per column. Null means every column is carried. false means the emit carries nothing for that column: an unchanged column in delta mode, or every column of a watermark-only advance. A null cell on a carried column is a genuine null. |
A compound-ranged property yields a multi-column rowset, one column per leaf, rather than a single
value. So subscribing to a TVQ-ranged property gives you timestamp, value and quality as separate
columns in one row. Subscribing to a compound parent URI does the same thing one level up.
A column's wire type is the column's declared xsd type, as resolved on the instance's type: the
TypeUri that SubscribeToTypeDescription reports for that leaf. A leaf declared xsd:boolean
arrives as a BoolColumn and one declared xsd:dateTime as a DateTimeColumn, whatever type the
source delivered the value in. A value a source sends in another type is converted on the way out,
and a value that cannot be converted is a null cell on that column, so a column never changes type
between rowsets. Every column has a declared type: a type that leaves a deferred leaf unpinned does
not compile (rule O-B23), so no instance you can subscribe to has an open leaf.
A relationship carries its two endpoints as columns#
Every relationship instance now carries two read-only string columns, named by the reserved
atlas:Source and atlas:Target vocabulary URIs and filled at compile time with the endpoint
instance URNs.
They behave as ordinary static columns rather than as anything special, so they arrive everywhere a
property does: full-row and delta SubscribeToInstance and SubscribeToInstanceOfType, SubscribeToInstancePropertyOfType on
either endpoint, the range reads, and GET .../instances/{instanceId}/values. You do not subscribe to
them separately, and you cannot write them.
A relationship type with no properties of its own used to disappear from a snapshot. With nothing to put in a row it produced no units at all and its instances were dropped. Those two columns are enough to give it one, so a relationship-only type now shows up where it previously looked like missing data.
Describing a type before you subscribe#
The value streams carry values. A fifth method, SubscribeToTypeDescription, carries the shape of a
type: its columns, its endpoints if it is a relationship, or its members if it is an enumeration.
A consumer learns a type's columns before, or without, subscribing to any instance. That lets you build your decoder up front, validate configuration against the real shape, and learn when a recompile changes it.
| Method | Follows | Arguments |
|---|---|---|
SubscribeToTypeDescription |
the compiled shape of one type | string typeUri |
It behaves like the value streams: the first item is the current state, then items arrive as it changes.
You consume it with StreamAsync, but it yields TypeDescriptionDto items rather than ValueRowSetDto.
In the shipped .NET client the call is SubscribeToTypeDescriptionAsync(typeUri, cancellationToken).
await foreach (var description in connection.StreamAsync<TypeDescriptionDto>(
"SubscribeToTypeDescription",
"urn:demo:Filler",
cancellationToken))
{
if (description.Deleted) { /* the type is gone; the stream ends after this item */ break; }
var columns = Leaves(description.Schema).Select(n => n.Column); // depth-first over leaves
RebuildDecoder(columns);
}
static IEnumerable<SchemaNodeDto> Leaves(IEnumerable<SchemaNodeDto> nodes) =>
nodes.SelectMany(n => n.Fields is null ? new[] { n } : Leaves(n.Fields));
TypeDescriptionDto#
The members are positional, [Key(0)] to [Key(9)], in this order.
| Member | Type | Meaning |
|---|---|---|
TypeUri |
string |
The canonical type URI as compiled. It is not necessarily the spelling you passed. |
Label |
string? |
The type's label, or null. |
Kind |
enum | Entity (0), Relationship (1) or Enum (2). |
ParentUri |
string? |
The direct user-defined parent type. Null at the root; a framework base counts as the root. |
Endpoints |
{ SourceTypes, TargetTypes }? |
Relationship types only: the allowed source and target entity types, each a string[]. One entry each normally, several when the endpoint is a union of types. Null for entity types, enum types and tombstones. |
Schema |
SchemaNodeDto[] |
The type's columns as a tree, in declaration order. Empty for an enum type and on a tombstone. |
Generation |
long |
The generation of the compiled world the description was built from. It increases on every recompile but restarts when Atlas restarts, so compare generations only within one Atlas session and treat a reconnect as a reason to resubscribe, not to compare numbers across it. |
T |
long |
The server send timestamp, in .NET ticks, as on rowsets. |
Deleted |
bool |
True on the tombstone: the type is gone from the compiled world. |
EnumType |
{ KeyType, Members }? |
Enum types only: the xsd key type and the members in authored order. Null for entity and relationship types and on a tombstone. |
SchemaNodeDto#
The members are positional, [Key(0)] to [Key(7)].
| Member | Type | Meaning |
|---|---|---|
Name |
string |
The local name. At the root it is the property's local name. Inside a data type it is the field name, inside a composite the member name (older hosts only), and on an endpoint Source or Target. |
Uri |
string? |
At the root, the property URI. For a composite member, its sub-component URI (propertyUri.member). Null for a field inside a data type. For an endpoint, the reserved vocabulary URI. |
Column |
string? |
The exact column name a rowset uses. Present on leaves only. Null on a Compound or Composite parent. |
TypeUri |
string |
On a leaf, the xsd type. On a Compound parent, the data-type URI. On a Composite parent, the entity-type URI (older hosts only). xsd:string on an endpoint. |
Kind |
enum | Scalar (0), Compound (1), Composite (2) or Endpoint (3). A data-type property is Compound whether its instances source it whole or field by field. Current hosts no longer emit Composite, but a consumer may still see it from an older host. |
IsTimeAxis |
bool |
True on the data-type field declared as the time axis. |
Fields |
SchemaNodeDto[]? |
The children of a Compound or Composite parent. Null on a leaf. |
EnumTypeUri |
string? |
Leaves only: the Enumeration Type whose keys type this column, or null when the column is not enum-typed. TypeUri still carries the xsd key type. |
EnumType holds KeyType (a string) and Members, a list of { Key, Name, Label? }. Key is the
member's wire value in its canonical lexical form, for example "10" under xsd:integer and "true"
under xsd:boolean.
Column names you can rely on#
- A root scalar's
Columnequals itsUri. - A data-type property's leaves are named
propertyUri.Field, and nested fieldspropertyUri.Field.Sub. - A data-type property's leaves keep these names however an instance sources them, whole or field by field
with the Composed strategy. A composite node from an older host names its member columns
propertyUri.member, with a data-type member's leaves under that aspropertyUri.member.Field. - A relationship type carries two
Endpointleaves. TheirUriandColumnare both the reserved vocabulary URIsurn:atlas:vocab:Sourceandurn:atlas:vocab:Target, typedxsd:string. These are the same two columns every relationship rowset carries.
Endpoints is a different fact from the two endpoint columns. The columns hold the endpoint instances.
Endpoints is the constraint on which types may sit at each end.
Walking Schema depth-first over its leaves gives the exact set of columns a full-row rowset of the
type carries.
Match rowset columns to the schema by name, never by position. The schema is in
declaration order, which is the column order of the delta variant (emitFullRow: false). The full-row
variant (emitFullRow: true) lists time-series properties before constant ones.
When items arrive#
The first item is the snapshot. After that, one item arrives per recompile whose shape differs from the last item you received. The shape is the label, kind, parent, endpoints, the schema tree, enum bindings, or an enum's roster. A recompile that only changes values or instances emits nothing.
When a recompile removes the type, one final item arrives with Deleted = true, an empty Schema, and
null Endpoints and EnumType. Then the stream completes normally. That is how you tell "the type is
gone" from "the connection dropped".
Streams do not auto-restore after a reconnect, the same as the value streams. Resubscribe.
If typeUri names neither a type nor an Enumeration Type in the pinned compiled world, the stream fails
with a HubException reading Could not resolve (branch/domain, typeUri).
Enumeration Types#
typeUri may also name an Enumeration Type. Its description has Kind = Enum, an empty Schema, and
null ParentUri and Endpoints. The roster is in EnumType.
A roster change emits a new description. That covers a member added, removed, reordered or relabelled, and a change of key type. Removing the enum sends a tombstone.
The natural flow is:
- Subscribe to an entity type.
- Notice
EnumTypeUrion a leaf. - Subscribe to that URI for the key roster.
- Resolve raw keys to labels on your side.
The raw key on the value streams is unchanged by any of this. The description is metadata only.
The usual pattern#
Describe the type, build your decoders from the leaf columns, call a GetRange* method to fill the
window, then call a Subscribe* method to keep it current. Keep the description stream open so that a
recompile which changes the shape tells you to rebuild.
Reading history over the same connection#
Four more methods return stored ranges. They stream in exactly the same way.
| Method | Arguments |
|---|---|
GetRangeInstanceProperty |
string instanceUri, string propertyUri, long fromTicks, long toTicks |
GetRangeInstance |
string instanceUri, long fromTicks, long toTicks |
GetRangeTypeProperty |
string typeUri, string propertyUri, long fromTicks, long toTicks |
GetRangeType |
string typeUri, long fromTicks, long toTicks |
The bounds are .NET ticks, not Unix epoch.
fromTicksandtoTicksarelongtick values, where one tick is 100 nanoseconds and the epoch is 0001-01-01. Passing Unix milliseconds gives you a window in the first century and an empty result with no error. In .NET useDateTime.Ticks; from another platform, convert explicitly.
An atomic property, meaning one with no time axis, yields a single current-value row rather than a series, which is the expected shape.
The usual pattern for a chart is one GetRange* call to fill the window, then a Subscribe* call to keep
it current. To build the decoder first, see Describing a type before you subscribe.
Errors and traps#
| Symptom | Cause | Fix |
|---|---|---|
HubException reading Could not resolve (branch/domain, instanceUri, propertyUri). |
The URI does not exist in the compiled world for the branch and domain pinned in your connection URL, or that world has not been compiled | Check the URIs, and check you are pinned to the world you think you are. Compile the domain if it has never been compiled |
HubException reading Could not resolve (branch/domain, typeUri). |
The URI names neither a type nor an Enumeration Type in the compiled world pinned by your connection URL | Check the URI and the pinned world. Compile the domain if it has never been compiled |
A SubscribeToTypeDescription stream completes on its own |
A tombstone arrived first (Deleted = true): a recompile removed the type |
Expected. Resubscribe once the type exists again. A dropped connection faults the stream instead |
| Leaf order in the schema does not match the column order of a full-row rowset | The schema is in declaration order. The full-row variant lists time-series properties before constant ones | Match columns by name, never by position |
| An enum-typed column arrives as a plain number or string | Expected. The value stream carries the raw key | Subscribe to the leaf's EnumTypeUri and resolve the key with that roster |
Handshake returns 401 |
Missing or invalid credential, or a token whose audience does not list Atlas's Pulse:Auth:ClientId |
Attach the token via AccessTokenProvider. Check the client's audiences in Pulse |
| Handshake returns HTML or a redirect | You are hitting a UI-plane route rather than the hub | Use the /hubs/v1/... path exactly |
| Connected, but nothing ever arrives | You registered a handler with connection.On(...) instead of consuming a stream |
Use StreamAsync. These methods return streams |
| Rowsets arrive with no values in them | You subscribed with includeWatermarks: true, and these are watermark-only advances. Every entry of Present is false |
Expected. Treat a rowset whose Present is all false as a progress signal, not as a row of nulls |
| Values are null and never change | The underlying leaf has never been sampled, so its cell holds no value and the row carries the column's null bit | Check that the binding path is right. A binding path that matches nothing produces exactly this until the path is corrected. A written zero arrives as 0, not null |
| Unchanged columns arrive as null and your target gets wiped | You subscribed with emitFullRow: false, which is the delta variant, and your consumer reads cells without checking Present |
Skip every column whose Present entry is false, or use emitFullRow: true |
| A range query returns nothing for a window you know has data | fromTicks and toTicks were sent as Unix time rather than .NET ticks |
Convert properly. See the note above |
| Two columns you did not define appear on a relationship | atlas:Source and atlas:Target are seeded on every relationship at compile time |
Expected. Read them if they are useful, ignore them otherwise. They are read-only |
| A relationship type used to be absent from snapshots and now appears | It had no properties of its own, so it produced no row at all until those two columns gave it one | Expected, and the previous behaviour was the fault |
Related articles#
- Atlas API Overview - the route table, auth policies and error envelope
- Registering Timebase apps as OAuth clients in Pulse - audiences and the bearer token
- Exporting Data with Bridge - the shipped consumer of this hub
- How Atlas Aligns and Times Values - watermarks, anchors and the lateness gate
- Timebase Atlas Architecture - where the hub sits