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.
| Item | Value |
|---|
| URL | ws://localhost:8080/ws |
| Production URL | wss://api.example.com/ws |
| Subprotocol | eneros.v1 (must be specified via Sec-WebSocket-Protocol during handshake) |
| Heartbeat Period | 30 seconds |
| Heartbeat Timeout | 60 seconds (disconnects if no pong response) |
| Max Subscriptions Per Connection | 50 topics |
| Max Connections Per Tenant | 20 concurrent |
| Max Message Size | 256 KB |
| Reconnection Replay | Automatically replays the latest 100 events after reconnection |
| Authentication Method | Send 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);
All messages are JSON text frames (binary frames not supported), with unified field conventions as follows:
| Field | Type | Required | Description |
|---|
type | string | Yes | Message type, see enumeration below |
id | string | No | Message ID, used for ACK correlation |
timestamp | string | No | Message timestamp (RFC 3339) |
payload | object | No | Message body, structure varies with type |
Message Type Enumeration
| Direction | Type | Description |
|---|
| Client → Server | auth | Authentication |
| Client → Server | subscribe | Subscribe to topics |
| Client → Server | unsubscribe | Unsubscribe |
| Client → Server | command | Send Agent command |
| Client → Server | ping | Heartbeat probe |
| Client → Server | ack | Acknowledge event receipt |
| Server → Client | auth_ok | Authentication success |
| Server → Client | auth_failed | Authentication failure |
| Server → Client | subscribed | Subscription confirmed |
| Server → Client | unsubscribed | Unsubscribe confirmed |
| Server → Client | event | Event push |
| Server → Client | command_result | Command execution result |
| Server → Client | pong | Heartbeat response |
| Server → Client | error | Error notification |
| Server → Client | reconnect | Notify 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"]
}
| Field | Type | Required | Default | Description |
|---|
token | string | Yes | — | Bearer Token |
client_id | string | No | Auto-generated | Client identifier |
client_version | string | No | "" | Client version |
capabilities | array | No | [] | 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"
}
| Field | Type | Required | Default | Description |
|---|
topics | array | Yes | — | Topic list, supports wildcard * |
ack | bool | No | false | Whether to require event ACK |
last_event_id | string | No | — | Resumption from this ID onwards |
subscribed Frame
{
"type": "subscribed",
"topics": ["network.net_8f3a2b", "agent.*", "alarm.critical"],
"pending_replay": 12
}
| Field | Type | Required | Description |
|---|
topics | array | Yes | List of subscribed topics |
pending_replay | int | Yes | Number 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"}
}
}
| Field | Type | Required | Description |
|---|
id | string | Yes | Event unique ID, used for ACK and resumption |
topic | string | Yes | Topic the event belongs to |
timestamp | string | Yes | Event timestamp |
payload.kind | string | Yes | Event type enumeration |
payload | object | Yes | Event 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
}
| Field | Type | Required | Default | Description |
|---|
id | string | Yes | — | Command ID, used for result correlation |
agent_id | string | Yes | — | Target Agent |
command | string | Yes | — | Command name |
params | object | No | {} | Command parameters |
priority | string | No | normal | low/normal/high/emergency |
timeout_ms | int | No | 5000 | Timeout 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"
}
| Field | Type | Required | Description |
|---|
id | string | Yes | Correlated command ID |
status | string | Yes | success / failed / timeout |
result | object | No | Execution result |
duration_ms | int | Yes | Execution duration |
error | object | No | Error 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 Prefix | Description | Wildcard | Example |
|---|
network.{id} | Grid topology/status changes | Supports network.* | network.net_8f3a2b |
agent.{id} | Agent status changes | Supports agent.* | agent.dispatch_01 |
alarm.{severity} | Alarms by severity | Supports alarm.* / alarm.critical | alarm.critical |
powerflow.{id} | Power flow calculation results | Supports powerflow.* | powerflow.net_8f3a2b |
device.{id} | Device online/offline | Supports device.* | device.dev_001 |
constraint.* | Constraint violation events | Wildcard only | constraint.* |
audit.* | Audit events (requires audit permission) | Wildcard only | audit.* |
task.{id} | Task status changes | Supports 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 kind | Trigger Condition | payload Key Fields |
|---|
TopologyChanged | Bus/branch topology change | changes, network_id |
NetworkCreated | Grid created | network_id, name |
NetworkUpdated | Grid attribute update | network_id, fields |
NetworkDeleted | Grid deleted | network_id |
NetworkLocked | Network locked (during calculation) | network_id, lock_holder |
NetworkUnlocked | Network unlocked | network_id |
PowerFlowEvent
| Event kind | Trigger Condition | payload Key Fields |
|---|
PowerFlowStarted | Power flow calculation started | task_id, network_id, method |
PowerFlowIteration | Each iteration | task_id, iteration, mismatch |
PowerFlowCompleted | Power flow converged | task_id, iterations, duration_ms |
PowerFlowDiverged | Power flow did not converge | task_id, reason |
AgentEvent
| Event kind | Trigger Condition | payload Key Fields |
|---|
AgentStatusChanged | Agent start/stop/status switch | agent_id, old_status, new_status |
AgentAction | Agent executes action | agent_id, command, params |
AgentError | Agent internal error | agent_id, error |
AgentHeartbeat | Agent heartbeat | agent_id, metrics |
AlarmEvent
| Event kind | Trigger Condition | payload Key Fields |
|---|
AlarmRaised | Alarm raised | alarm_id, severity, message |
AlarmAcknowledged | Alarm acknowledged | alarm_id, acknowledged_by |
AlarmCleared | Alarm cleared | alarm_id, reason |
DeviceEvent
| Event kind | Trigger Condition | payload Key Fields |
|---|
DeviceOnline | Device online | device_id, metadata |
DeviceOffline | Device offline | device_id, reason |
DeviceMeasurement | Device measurement report | device_id, metric, value |
ConstraintEvent
| Event kind | Trigger Condition | payload Key Fields |
|---|
ConstraintViolation | Safety constraint violation triggered | element, quantity, value, limit |
ConstraintRestored | Constraint restored to normal | element, 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 Code | Meaning | Suggested Handling |
|---|
AUTH_FAILED | Token invalid or expired | Re-obtain token |
AUTH_TIMEOUT | auth frame not sent within 5 seconds | Send authentication frame immediately |
TOPIC_DENIED | No subscription permission | Contact administrator to adjust policy |
TOPIC_LIMIT_EXCEEDED | Exceeded max subscriptions per connection | Unsubscribe some topics |
RATE_LIMITED | Send rate exceeded | Reduce dispatch frequency |
INVALID_MESSAGE | Message format error | Validate JSON structure |
MESSAGE_TOO_LARGE | Message exceeds 256KB | Split the message |
AGENT_NOT_FOUND | Command target Agent does not exist | Verify agent_id |
AGENT_BUSY | Agent busy, cannot process command | Queue or retry later |
COMMAND_TIMEOUT | Command execution timeout | Increase timeout or check Agent |
SESSION_EXPIRED | Session expired | Re-establish connection |
SERVER_SHUTDOWN | Server is about to shut down | Reconnect to another node immediately |
Close Codes
WebSocket close codes follow RFC 6455, with the following custom close codes extended:
| Close Code | Meaning | Suggested Handling |
|---|
| 1000 | Normal closure | — |
| 1006 | Abnormal closure (no close frame) | Check network |
| 1011 | Server internal error | Reconnect |
| 4001 | Authentication failed | Re-authenticate |
| 4002 | Heartbeat timeout | Check network |
| 4003 | Subscription limit exceeded | Unsubscribe some topics |
| 4004 | Rate limited | Reconnect after backoff |
| 4005 | Server maintenance | Reconnect after 5 minutes |
| Dimension | Limit |
|---|
| Max subscriptions per connection | 50 topics |
| Message rate per connection | 100 msg/s (inbound) |
| Event rate per connection | 1000 msg/s (outbound) |
| Max message size | 256 KB |
| Max connections per tenant | 20 |
| Connection idle timeout | 5 minutes (no messages) |
| Event retention period | 24 hours (for resumption) |