Skip to main content

WebSocket

API Reference

WebSocket Event Stream

EnerOS provides bidirectional real-time communication channels via WebSocket, used for scenarios such as Agent status push, grid event broadcast, and remote command dispatch. The underlying events are generated by eneros-eventbus, authenticated by eneros-gateway, and then pushed to subscribed clients. The WebSocket protocol is based on HTTP upgrade handshake, and the connection can be maintained as a long connection after establishment. Compared with REST’s repeated connection establishment, it can significantly reduce latency and resource consumption.

Endpoint Information

ItemValue
URLws://localhost:8080/ws
Production URLwss://api.example.com/ws
Subprotocoleneros.v1 (must be specified via Sec-WebSocket-Protocol during handshake)
Heartbeat Period30 seconds
Heartbeat Timeout60 seconds (disconnects if no pong response)
Max Subscriptions Per Connection50 topics
Max Connections Per Tenant20 concurrent
Max Message Size256 KB
Reconnection ReplayAutomatically replays the latest 100 events after reconnection
Authentication MethodSend auth frame as the first message after handshake

Connection Establishment

Handshake Flow

Client                                          Server
  │                                               │
  │── HTTP Upgrade + Sec-WebSocket-Protocol ────→ │
  │                                               │
  │←── 101 Switching Protocols ──────────────────│
  │                                               │
  │── auth frame (with token) ──────────────────→ │
  │                                               │
  │←── auth_ok frame ────────────────────────────│
  │                                               │
  │── subscribe frame (subscribe topics) ───────→ │
  │                                               │
  │←── subscribed frame (subscription confirmed) ─│
  │                                               │
  │←── event frame (event push) ─────────────────│
  │                                               │
  │── ping frame (every 30s) ───────────────────→ │
  │                                               │
  │←── pong frame ───────────────────────────────│

JavaScript Handshake Example

const ws = new WebSocket('ws://localhost:8080/ws', ['eneros.v1']);

ws.onopen = () => {
  console.log('Connection established, sending authentication');
  ws.send(JSON.stringify({
    type: 'auth',
    token: '<bearer-token>',
    client_id: 'dashboard-001',
    client_version: '1.0.0',
  }));
};

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  switch (msg.type) {
    case 'auth_ok':
      console.log('Authentication successful, starting subscription');
      ws.send(JSON.stringify({
        type: 'subscribe',
        topics: ['network.net_8f3a2b', 'agent.*', 'alarm.critical'],
        ack: true,
      }));
      break;
    case 'event':
      console.log('Received event:', msg.payload);
      break;
    case 'error':
      console.error('Error:', msg.payload);
      break;
  }
};

ws.onerror = (e) => console.error('Connection error', e);
ws.onclose = (e) => console.log('Connection closed:', e.code, e.reason);

Message Format

All messages are JSON text frames (binary frames not supported), with unified field conventions as follows:

FieldTypeRequiredDescription
typestringYesMessage type, see enumeration below
idstringNoMessage ID, used for ACK correlation
timestampstringNoMessage timestamp (RFC 3339)
payloadobjectNoMessage body, structure varies with type

Message Type Enumeration

DirectionTypeDescription
Client → ServerauthAuthentication
Client → ServersubscribeSubscribe to topics
Client → ServerunsubscribeUnsubscribe
Client → ServercommandSend Agent command
Client → ServerpingHeartbeat probe
Client → ServerackAcknowledge event receipt
Server → Clientauth_okAuthentication success
Server → Clientauth_failedAuthentication failure
Server → ClientsubscribedSubscription confirmed
Server → ClientunsubscribedUnsubscribe confirmed
Server → ClienteventEvent push
Server → Clientcommand_resultCommand execution result
Server → ClientpongHeartbeat response
Server → ClienterrorError notification
Server → ClientreconnectNotify client to reconnect (server is about to close)

auth Frame

{
  "type": "auth",
  "token": "<bearer-token>",
  "client_id": "dashboard-001",
  "client_version": "1.0.0",
  "capabilities": ["ack", "batch"]
}
FieldTypeRequiredDefaultDescription
tokenstringYesBearer Token
client_idstringNoAuto-generatedClient identifier
client_versionstringNo""Client version
capabilitiesarrayNo[]Client capability declaration

auth_ok Frame

{
  "type": "auth_ok",
  "session_id": "sess_20260706_001",
  "tenant": "default",
  "expires_at": "2026-07-06T09:30:00Z",
  "server_time": "2026-07-06T08:30:00Z"
}

subscribe Frame

{
  "type": "subscribe",
  "topics": ["network.net_8f3a2b", "agent.*", "alarm.critical"],
  "ack": true,
  "last_event_id": "evt_20260706_001"
}
FieldTypeRequiredDefaultDescription
topicsarrayYesTopic list, supports wildcard *
ackboolNofalseWhether to require event ACK
last_event_idstringNoResumption from this ID onwards

subscribed Frame

{
  "type": "subscribed",
  "topics": ["network.net_8f3a2b", "agent.*", "alarm.critical"],
  "pending_replay": 12
}
FieldTypeRequiredDescription
topicsarrayYesList of subscribed topics
pending_replayintYesNumber of events pending replay

event Frame

{
  "type": "event",
  "id": "evt_a1b2c3",
  "topic": "network.net_8f3a2b",
  "timestamp": "2026-07-06T08:30:00.123Z",
  "payload": {
    "kind": "TopologyChanged",
    "network_id": "net_8f3a2b",
    "changes": [{"branch": "1-2", "status": "open"}],
    "actor": {"type": "agent", "id": "agent_dispatch_01"}
  }
}
FieldTypeRequiredDescription
idstringYesEvent unique ID, used for ACK and resumption
topicstringYesTopic the event belongs to
timestampstringYesEvent timestamp
payload.kindstringYesEvent type enumeration
payloadobjectYesEvent specific content

command Frame

{
  "type": "command",
  "id": "cmd_001",
  "agent_id": "agent_dispatch_01",
  "command": "set_generation",
  "params": {"bus": 2, "mw": 50.0},
  "priority": "normal",
  "timeout_ms": 5000
}
FieldTypeRequiredDefaultDescription
idstringYesCommand ID, used for result correlation
agent_idstringYesTarget Agent
commandstringYesCommand name
paramsobjectNo{}Command parameters
prioritystringNonormallow/normal/high/emergency
timeout_msintNo5000Timeout duration

command_result Frame

{
  "type": "command_result",
  "id": "cmd_001",
  "status": "success",
  "result": {"bus": 2, "mw_set": 50.0, "audit_id": "aud_001"},
  "duration_ms": 12,
  "timestamp": "2026-07-06T08:30:00Z"
}
FieldTypeRequiredDescription
idstringYesCorrelated command ID
statusstringYessuccess / failed / timeout
resultobjectNoExecution result
duration_msintYesExecution duration
errorobjectNoError information on failure

error Frame

{
  "type": "error",
  "code": "TOPIC_DENIED",
  "message": "No permission to subscribe: audit.*",
  "details": {"topic": "audit.*"},
  "timestamp": "2026-07-06T08:30:00Z"
}

Event Topic List

Topic PrefixDescriptionWildcardExample
network.{id}Grid topology/status changesSupports network.*network.net_8f3a2b
agent.{id}Agent status changesSupports agent.*agent.dispatch_01
alarm.{severity}Alarms by severitySupports alarm.* / alarm.criticalalarm.critical
powerflow.{id}Power flow calculation resultsSupports powerflow.*powerflow.net_8f3a2b
device.{id}Device online/offlineSupports device.*device.dev_001
constraint.*Constraint violation eventsWildcard onlyconstraint.*
audit.*Audit events (requires audit permission)Wildcard onlyaudit.*
task.{id}Task status changesSupports task.*task.pf_20260706_001

Wildcard rules:

  • * matches a single level, e.g., agent.* matches agent.dispatch_01 but not agent.dispatch_01.sub
  • # matches multiple levels (MQTT compatibility mode only)

Event Type Enumeration

NetworkEvent

Event kindTrigger Conditionpayload Key Fields
TopologyChangedBus/branch topology changechanges, network_id
NetworkCreatedGrid creatednetwork_id, name
NetworkUpdatedGrid attribute updatenetwork_id, fields
NetworkDeletedGrid deletednetwork_id
NetworkLockedNetwork locked (during calculation)network_id, lock_holder
NetworkUnlockedNetwork unlockednetwork_id

PowerFlowEvent

Event kindTrigger Conditionpayload Key Fields
PowerFlowStartedPower flow calculation startedtask_id, network_id, method
PowerFlowIterationEach iterationtask_id, iteration, mismatch
PowerFlowCompletedPower flow convergedtask_id, iterations, duration_ms
PowerFlowDivergedPower flow did not convergetask_id, reason

AgentEvent

Event kindTrigger Conditionpayload Key Fields
AgentStatusChangedAgent start/stop/status switchagent_id, old_status, new_status
AgentActionAgent executes actionagent_id, command, params
AgentErrorAgent internal erroragent_id, error
AgentHeartbeatAgent heartbeatagent_id, metrics

AlarmEvent

Event kindTrigger Conditionpayload Key Fields
AlarmRaisedAlarm raisedalarm_id, severity, message
AlarmAcknowledgedAlarm acknowledgedalarm_id, acknowledged_by
AlarmClearedAlarm clearedalarm_id, reason

DeviceEvent

Event kindTrigger Conditionpayload Key Fields
DeviceOnlineDevice onlinedevice_id, metadata
DeviceOfflineDevice offlinedevice_id, reason
DeviceMeasurementDevice measurement reportdevice_id, metric, value

ConstraintEvent

Event kindTrigger Conditionpayload Key Fields
ConstraintViolationSafety constraint violation triggeredelement, quantity, value, limit
ConstraintRestoredConstraint restored to normalelement, quantity

Heartbeat Mechanism

The server sends a WebSocket protocol-level ping frame every 30 seconds, and the client must respond with a pong frame within 60 seconds. If the client uses application-level heartbeats (e.g., browsers cannot control protocol frames), it can send a ping message:

{"type": "ping", "timestamp": "2026-07-06T08:30:00Z"}

Server response:

{"type": "pong", "timestamp": "2026-07-06T08:30:00.001Z"}

Reconnection Mechanism

Clients should implement exponential backoff reconnection after disconnection:

class EnerOSWebSocket {
  constructor(url, token) {
    this.url = url;
    this.token = token;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10;
    this.lastEventId = null;
    this.subscriptions = new Set();
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url, ['eneros.v1']);
    this.ws.onopen = () => this.onOpen();
    this.ws.onmessage = (e) => this.onMessage(e);
    this.ws.onclose = (e) => this.onClose(e);
    this.ws.onerror = (e) => console.error('WS error', e);
  }

  onOpen() {
    this.reconnectAttempts = 0;
    this.ws.send(JSON.stringify({
      type: 'auth',
      token: this.token,
    }));
  }

  onMessage(event) {
    const msg = JSON.parse(event.data);
    if (msg.type === 'auth_ok') {
      if (this.subscriptions.size > 0) {
        this.ws.send(JSON.stringify({
          type: 'subscribe',
          topics: Array.from(this.subscriptions),
          last_event_id: this.lastEventId,
        }));
      }
    } else if (msg.type === 'event') {
      this.lastEventId = msg.id;
      this.handleEvent(msg);
    }
  }

  onClose(event) {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached, giving up');
      return;
    }
    const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
    this.reconnectAttempts++;
    console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
    setTimeout(() => this.connect(), delay);
  }

  handleEvent(msg) {
    console.log('Event:', msg);
  }

  subscribe(topic) {
    this.subscriptions.add(topic);
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({type: 'subscribe', topics: [topic]}));
    }
  }

  unsubscribe(topic) {
    this.subscriptions.delete(topic);
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({type: 'unsubscribe', topics: [topic]}));
    }
  }
}

Rust Client Example

use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{SinkExt, StreamExt};
use serde_json::json;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let (ws_stream, _) = connect_async("ws://localhost:8080/ws").await?;
    let (mut write, mut read) = ws_stream.split();

    // Authentication
    let auth_msg = json!({
        "type": "auth",
        "token": "<bearer-token>",
        "client_id": "rust-client-01",
    });
    write.send(Message::Text(auth_msg.to_string())).await?;

    // Wait for auth_ok
    if let Some(Ok(Message::Text(text))) = read.next().await {
        let resp: serde_json::Value = serde_json::from_str(&text)?;
        if resp["type"] == "auth_ok" {
            println!("Authentication successful");
        }
    }

    // Subscribe
    let sub_msg = json!({
        "type": "subscribe",
        "topics": ["network.net_8f3a2b", "alarm.critical"],
    });
    write.send(Message::Text(sub_msg.to_string())).await?;

    // Receive event loop
    while let Some(Ok(msg)) = read.next().await {
        match msg {
            Message::Text(text) => {
                let evt: serde_json::Value = serde_json::from_str(&text)?;
                if evt["type"] == "event" {
                    println!(
                        "[{}] {}: {:?}",
                        evt["timestamp"], evt["topic"], evt["payload"]
                    );
                }
            }
            Message::Ping(data) => {
                write.send(Message::Pong(data)).await?;
            }
            _ => {}
        }
    }

    Ok(())
}

Python Client Example

import asyncio
import json
import websockets

async def main():
    uri = "ws://localhost:8080/ws"
    async with websockets.connect(uri, subprotocols=["eneros.v1"]) as ws:
        # Authentication
        await ws.send(json.dumps({
            "type": "auth",
            "token": "<bearer-token>",
            "client_id": "python-client-01",
        }))

        # Wait for auth_ok
        response = json.loads(await ws.recv())
        if response["type"] != "auth_ok":
            print("Authentication failed:", response)
            return

        print("Authentication successful")

        # Subscribe
        await ws.send(json.dumps({
            "type": "subscribe",
            "topics": ["network.net_8f3a2b", "alarm.critical"],
        }))

        # Event loop
        async for message in ws:
            evt = json.loads(message)
            if evt["type"] == "event":
                print(f"[{evt['timestamp']}] {evt['topic']}: {evt['payload']}")
            elif evt["type"] == "ping":
                await ws.send(json.dumps({"type": "pong"}))

asyncio.run(main())

ACK and Reliable Delivery

When ACK is enabled, the client must send an ack frame for each event:

{
  "type": "ack",
  "event_id": "evt_a1b2c3"
}

If no ACK is received within 5 seconds, the server will resend the event (up to 3 times). ACK mode is suitable for critical alarm and control command scenarios.

Error Codes

Error CodeMeaningSuggested Handling
AUTH_FAILEDToken invalid or expiredRe-obtain token
AUTH_TIMEOUTauth frame not sent within 5 secondsSend authentication frame immediately
TOPIC_DENIEDNo subscription permissionContact administrator to adjust policy
TOPIC_LIMIT_EXCEEDEDExceeded max subscriptions per connectionUnsubscribe some topics
RATE_LIMITEDSend rate exceededReduce dispatch frequency
INVALID_MESSAGEMessage format errorValidate JSON structure
MESSAGE_TOO_LARGEMessage exceeds 256KBSplit the message
AGENT_NOT_FOUNDCommand target Agent does not existVerify agent_id
AGENT_BUSYAgent busy, cannot process commandQueue or retry later
COMMAND_TIMEOUTCommand execution timeoutIncrease timeout or check Agent
SESSION_EXPIREDSession expiredRe-establish connection
SERVER_SHUTDOWNServer is about to shut downReconnect to another node immediately

Close Codes

WebSocket close codes follow RFC 6455, with the following custom close codes extended:

Close CodeMeaningSuggested Handling
1000Normal closure
1006Abnormal closure (no close frame)Check network
1011Server internal errorReconnect
4001Authentication failedRe-authenticate
4002Heartbeat timeoutCheck network
4003Subscription limit exceededUnsubscribe some topics
4004Rate limitedReconnect after backoff
4005Server maintenanceReconnect after 5 minutes

Performance and Limits

DimensionLimit
Max subscriptions per connection50 topics
Message rate per connection100 msg/s (inbound)
Event rate per connection1000 msg/s (outbound)
Max message size256 KB
Max connections per tenant20
Connection idle timeout5 minutes (no messages)
Event retention period24 hours (for resumption)