Skip to main content
Version: 0.1

Live WebSocket (/sync)

/sync is the gateway's live channel: one WebSocket that carries tag values as they change, writes, history reads, replays, alarm frames and screen event scripts. This page is for anyone talking to it directly; the TypeScript SDK does all of this for you.

Connecting​

Open wss://<host>/sync (or ws:// when the gateway is served over plain HTTP). The gateway accepts the upgrade when:

  • The request carries a signed-in session. In a browser the session cookie is sent with the upgrade, so a page served from the same origin needs nothing else. A browser's WebSocket cannot set headers, so an API key cannot be used from a page. API keys are for the REST API, not for live data: a non-browser client that puts Authorization: Bearer <key> on the upgrade request is accepted and listed on Gateway › Clients as an apiKey client, but the gateway re-checks the role of an open socket through its session, and a key has none. That socket is closed at the next periodic check (within 30 seconds) and tag.write on it is refused. Use a signed-in session for /sync.
  • The Origin header is one of AUTH_TRUSTED_ORIGINS. A missing, empty or null origin is refused. Browsers send cookies on cross-site WebSocket upgrades, so this check is what stops another page from driving a signed-in session.
  • The principal holds at least the viewer role. Subscribing and replaying need nothing more; tag.write needs operator.
RefusalStatus
Not a WebSocket upgrade400
No session or API key401
Untrusted Origin403
The user already holds 32 sockets429

Once open, the socket re-reads the session every 30 seconds and closes (close status 1008, policy violation) when the session has ended or the role is no longer a known one. A write checks the stored role on its own, so a demoted operator's next write fails at once.

Frame format​

Every message in either direction is one JSON text frame of the form {"event": "<name>", "data": <payload>}. The gateway sends one frame per message. An inbound frame larger than 1 MiB closes the socket (close status 1009). Tag paths are at most 512 characters. A frame the gateway cannot handle (unknown event, missing field, bad value) is logged and ignored; it does not end the session. The one exception is a failed tag.write, which is answered with tag.write.error.

A device can report NaN; the gateway writes it as the bare token NaN, which JSON.parse in a browser rejects. Byte values are sent as base64 strings and JSON-kind values as the parsed JSON.

Client frames and replies​

Client sendsGateway replies
tag.subscribe {path}tag.value now if the tag has a value, then on every change
tag.unsubscribe {path}Nothing
tag.write {path, value}tag.write when accepted, or tag.write.error {path, error}
tag.replay {path, start, end, maxPoints?}tag.replay {...} or tag.replay.error {path, start, end, error}
replay.start {id?, start, end?, paths?}replay.status, then replay.value and replay.alarm frames
replay.pause {}, replay.resume {}, replay.stop {}replay.status
alarm.subscribe {}alarm.snapshot {alarms}, then alarm.* frames
alarm.unsubscribe {}Nothing
screen.event {requestId, screenId, event, ...}screen.event {requestId, ok, directives, errors}

The gateway also sends one unsolicited frame, tags.changed {parents}, described below.

tag.subscribe and tag.value​

tag.subscribe {path: string} adds the path to the socket's subscriptions. Values reach a subscriber only after the event backbone has durably accepted them. Each socket may hold 10,000 subscriptions; the 10,001st is refused and nothing is sent. Subscribing twice to the same path is harmless.

tag.value data:

FieldTypeMeaning
pathstringThe tag path
valueanyThe engineering value (after scaling). Structures are JSON objects, bytes a base64 string
qualitystringGood, Uncertain or Bad
timestampnumberUnix milliseconds
rawValue?anyThe source value before scaling. Present only when a scaling transform applied
qualityDetail?stringWhy the quality is not Good: a script error, a lost link
private?booleantrue when the value belongs to this user or session (a scoped memory tag). A replay never holds those

Values under @gateway.browser.* are held by the socket alone: a tag.write to such a path stores the value on the session (at most 256 values of 64 KiB each), echoes it back as a tag.value with quality Good, and needs no role beyond viewer. They are for a browser reporting its own state.

tag.write​

tag.write {path: string, value: any} writes a value. The reply is tag.write whose data is the string "Write accepted for tag: <path>", or tag.write.error {path, error} with one of these errors:

ErrorCause
Your role doesn't allow writing to tags.The stored role is below operator
Replay is on. Return to live to change values.The socket is in a replay. Private values and @gateway.browser.* stay writable
The write timed out.The device did not answer within 15 seconds
This session has too much browser data.The @gateway.browser.* limits above
The gateway could not complete this write.Anything else that failed
The tag's own reasonThe runtime or driver rejected the write

Every accepted write is recorded in the audit log.

tag.replay (history read)​

tag.replay {path: string, start: number, end: number, maxPoints?: number} reads a tag's history for a range in Unix milliseconds; maxPoints defaults to 1000. The answer comes from a running history module when there is one, otherwise from what the event backbone retains; when the module fails or takes longer than 15 seconds, the backbone answers instead. The reply is tag.replay with:

FieldTypeMeaning
path, start, endEchoed from the request
points{timestamp, value, quality}[]The values in the range, reduced to maxPoints
preBoundary{timestamp, value, quality} | nullThe last value before start, so a chart can draw from the left edge
samplesnumberStored samples in the range before any reduction
sourcestringThe module's id, or backbone
retainedFromnumber | nullThe oldest time the source has; a range starting earlier has no data before this
truncatedbooleanThe read stopped short of end, at coveredUntil. The backbone reads at most 10,000 stored batches from the start of a range
coveredUntilnumber | nullThe newest time the source read or holds
catchingUpbooleanThe module is behind the live stream, so recent values may be missing
gaps{from, to}[] | nullPeriods the module never received
fallbackReasonstring | nullWhy an installed module did not answer

On failure the reply is tag.replay.error {path, start, end, error} with the error The gateway could not read this tag's history. See History and replay for what the sources keep.

Replay​

A replay plays the recorded past back to one socket: the tag values, the gateway clock (@gateway.time) and the alarms. It is private to the socket; its frames are replay.value and replay.alarm, never tag.value or alarm.*, so live state and other clients are untouched.

replay.start data:

FieldTypeMeaning
id?stringYour id for the replay, up to 128 characters; the gateway makes one when it is missing. Every replay frame carries it
startnumberUnix milliseconds. Required
end?numberUnix milliseconds. Without it the replay runs until it has caught up with the present
paths?string[]Exact tag paths, or prefixes ending in *. Without it every tag the session may read is replayed

The gateway then sends what every tag held at start as replay.value frames with initial: true, the clock at start, the alarms outstanding at start as a replay.alarm with kind: "snapshot", and a replay.status with opened: true. From there it plays the recording at the recorded pace (gaps longer than 5 seconds are shortened to 5), with the clock once a second and each alarm event at its recorded time. Replays cannot be sped up or slowed down. Starting a second replay ends the first; the gateway runs at most 8 at once. Tags private to a user or session are never replayed, and @gateway tags other than the clock keep their live value. Closing the socket ends the replay.

replay.value {id, path, value, quality, timestamp, rawValue?, qualityDetail?, initial?} has the fields of tag.value plus the replay id and initial.

replay.alarm is either {id, kind: "snapshot", alarms: Alarm[]} (most severe first) or {id, kind, alarm, at} where kind is active, acknowledged, cleared, shelved or unshelved, alarm is the alarm in the state the event left it, and at is when it was recorded.

replay.status {id, state, start, end, position, sent, opened, message?, warning?} is sent on every change and about four times a second while playing:

stateMeaning
playingFollowing the recording
pausedAfter replay.pause, until replay.resume
finishedThe end, or the end of the recording, was reached. The socket is still in replay
stoppedAfter replay.stop
errorThe gateway stopped or refused it; message says why, for example A start time in Unix milliseconds is required. or The gateway is already running as many replays as it can.

position is the recorded time reached, sent how many values have gone out, opened whether the opening state is complete, and warning something that did not stop the replay but a person should know, such as alarm history that could not be read.

replay.pause, replay.resume and replay.stop take {} as data. A socket is in replay from replay.start until replay.stop, finished included, and during that time tag.write and screen.event are refused.

Alarms​

alarm.subscribe {} answers with alarm.snapshot {alarms: Alarm[]}, the outstanding alarms, then one frame per change with the alarm (the full object from the alarms API) as data:

FrameWhen
alarm.activeThe alarm went active
alarm.acknowledgedSomeone acknowledged it; see acknowledgedBy and acknowledgedByName
alarm.clearedThe condition ended. state says whether it still awaits an acknowledgement
alarm.updatedIt went stale or recovered, or an outstanding alarm's definition changed
alarm.removedIts definition or tag was deleted

A frame whose alarm has state: "normal" is no longer outstanding. alarm.unsubscribe {} stops the frames. See Alarms for the states.

screen.event​

screen.event runs a stored screen event script on the gateway. Only the address of the script travels; its source never leaves the server.

FieldTypeMeaning
requestIdstringUp to 128 characters, echoed in the reply so you can match it. Required
screenIdstringThe screen (the API calls them views)
componentLocalId?stringThe component whose event fires; absent for a screen-level event
eventstringThe event name
actionPath?number[]Index path to one action inside the event, through then chains
paramValues?Record<string, string>Values for the screen's parameters
value?anyThe event payload the script sees as event.value

The reply is screen.event {requestId, ok, directives, errors}. directives is a list of {toast?, navigate?, openPopup?, openUrl?} objects the script returned; errors is a list of strings. A connection runs at most 8 scripts at once (This connection is already running too many scripts.), and a socket in replay gets ok: false with Replay is on. Return to live to change values.. Scripts run with the session's identity and role; see Screen scripts.

tags.changed​

The gateway sends tags.changed {parents: string[] | null} to every open socket when the tag tree changes: tags added, removed, renamed or edited by anyone, or a folder the gateway fills itself (such as @gateway.clients) gaining or losing a child. parents names the folders whose children changed, "" being the root; null means reload everything you show. Changes within 300 ms go out as one frame.

Backpressure and limits​

Each socket has an outbound queue of 4,096 frames. When a client reads too slowly the oldest queued frames are dropped rather than slowing the runtime, so a subscriber can miss values. When the queue holds more than 32 MiB of text the socket is closed (close status 1008). The Clients page shows each socket's dropped-frame count.

LimitValue
Sockets per user32
Subscriptions per socket10,000
Inbound frame1 MiB
Tag path512 characters
Session re-checkEvery 30 seconds; the socket closes within 30 seconds of the session ending
Write timeout15 seconds
Running screen scripts per socket8
Replays per gateway8; one per socket
Outbound queue4,096 frames, 32 MiB

Reconnecting​

The gateway keeps no state for a closed socket. After reconnecting:

  1. Send tag.subscribe again for every path; the gateway answers each with its current value.
  2. Send alarm.subscribe again; the alarm.snapshot covers whatever changed while you were away.
  3. Because a slow socket can lose frames, anything that must stay exact should also re-read GET /api/v1/alarms/active now and then. The SDK's useAlarms does this every 30 seconds.
  4. A replay died with the socket. Send replay.start again from the last position you saw.

The SDK does all four itself and retries the connection every 3 seconds.

Example​

A page served from the gateway's origin (so the session cookie and Origin are right) that follows one tag and writes it:

const proto = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${proto}//${location.host}/sync`);

const send = (event, data) => socket.send(JSON.stringify({ event, data }));

socket.onopen = () => {
send("tag.subscribe", { path: "Line1.Oven.Temperature" });
send("alarm.subscribe", {});
};

socket.onmessage = ({ data }) => {
const { event, data: payload } = JSON.parse(data);
switch (event) {
case "tag.value":
console.log(payload.path, payload.value, payload.quality, new Date(payload.timestamp));
break;
case "tag.write.error":
console.warn(`Write to ${payload.path} refused: ${payload.error}`);
break;
case "alarm.snapshot":
console.log(`${payload.alarms.length} alarms outstanding`);
break;
default:
if (event.startsWith("alarm.")) console.log(event, payload.tagPath, payload.state);
}
};

// Needs the operator role.
function setSetpoint(value) {
send("tag.write", { path: "Line1.Oven.Setpoint", value });
}

For a React application, use the TypeScript SDK instead; it adds reconnection, reference-counted subscriptions, optimistic writes and replay routing on top of these frames.