# StreamHub WebSocket API

StreamHub exposes a single WebSocket server (default port **8090**, any URL path).
Text frames carry JSON commands/events; binary frames carry data pushes and
trigger captures. The Go reference hub (`Client/udpstreamer`) implements the
identical protocol; both the browser SPA and the ImGui client work against either.

For the upstream UDPS wire format (source → hub) see [Protocol.md](Protocol.md).

All numbers in binary frames are **little-endian**. All timestamps are
**Unix wall-clock seconds** (float64) — see *Time base* in
[StreamHub-Developer.md](StreamHub-Developer.md).

---

## 1. Commands (client → hub, JSON text frames)

Every command is a JSON object with a `type` field.

### `ping`

```json
{"type":"ping"}
```
Reply (unicast): `{"type":"pong"}`.

### `addSource`

```json
{"type":"addSource","label":"PSU","addr":"192.168.0.10:44500",
 "multicastGroup":"239.0.0.1","dataPort":44503}
```
- `addr` — `host:port` of the UDPStreamer control port.
- `multicastGroup` / `dataPort` — optional; if present the hub joins the
  multicast group for DATA and keeps a TCP control connection for CONNECT/CONFIG.
- The hub assigns ids `s1`, `s2`, … and broadcasts an updated `sources` event.

### `removeSource`

```json
{"type":"removeSource","id":"s1"}
```

### `saveSources`

```json
{"type":"saveSources"}
```
Persists the current dynamically-added source list to the hub's `SourcesFile`
(JSON array of `{label,addr,multicastGroup,dataPort}`); it is reloaded at startup.

### `getSources` / `getConfig` / `getStats`

```json
{"type":"getSources"}
{"type":"getConfig","sourceId":"s1"}
{"type":"getStats"}
```
Force a broadcast of the corresponding event.

### `setTrigger`

```json
{"type":"setTrigger","signal":"s1:Sine","edge":"rising","threshold":0.0,
 "windowSec":0.1,"prePercent":20,"mode":"normal"}
```
- `signal` — full key `src:sig`, or `src:sig[i]` to trigger on element *i* of a
  multi-element PACKET signal.
- `edge` — `"rising"`, `"falling"` or `"both"`.
- `windowSec` — total capture window (clamped to 1e-4 … 10 s).
  `preSec = windowSec * prePercent / 100`, `postSec = windowSec − preSec`.
- `mode` — `"normal"` (auto-rearm ~200 ms after capture) or `"single"`
  (stays TRIGGERED until `rearm`).

### `arm` / `disarm` / `rearm` / `trigStop`

```json
{"type":"arm"}
{"type":"disarm"}
{"type":"rearm"}
{"type":"trigStop","stopped":true}
```
- `arm` — IDLE → ARMED.
- `disarm` — any state → IDLE.
- `rearm` — TRIGGERED → ARMED (single mode).
- `trigStop` — sets the *stopped* flag suppressing auto-rearm in normal mode;
  omit `stopped` to toggle.

Every transition is broadcast as a `triggerState` event.

### `zoom`

```json
{"type":"zoom","reqId":17,"t0":1765370000.123,"t1":1765370000.223,
 "n":2400,"signals":"s1:Sine,s2:Wave[0]"}
```
- `reqId` — echoed in the reply; lets the client match request/response.
- `t0`/`t1` — Unix seconds window.
- `n` — target points per signal. Absent or `<10` → 2400; `n ≤ 0` → **no
  decimation** (raw ring contents).
- `signals` — comma-separated full keys; absent → all signals of all sources.
- The reply is **unicast** to the requesting client only.

### `historyZoom`

```json
{"type":"historyZoom","reqId":42,"t0":1765360000.0,"t1":1765370000.0,
 "n":2400,"signals":"s1:Sine,s2:Wave"}
```
- Reads from **disk-backed history** instead of the in-memory ring buffer.
  Identical semantics to `zoom` but queries the `.shist` files written by
  `HistoryWriter`.
- `reqId`, `t0`/`t1`, `n`, `signals` — same meaning as `zoom`.
- If history is not enabled, the reply contains `"error":"history not enabled"`.
- Reply is **unicast** (same shape as `zoom` reply, but `"type":"historyZoom"`).

### `historyInfo`

```json
{"type":"historyInfo"}
```
Request the hub to send a `historyInfo` event (unicast). Also sent automatically
on client connect.

### `setMaxPoints`

```json
{"type":"setMaxPoints","maxPoints":50000}
```
Resizes all ring buffers (applied safely inside the push loop; push cursors are
reset). Broadcasts `maxPointsUpdated`.

---

## 2. Events (hub → client, JSON text frames)

### `sources`

```json
{"type":"sources","sources":[
  {"id":"s1","label":"PSU","addr":"192.168.0.10:44500","state":"streaming"}]}
```
Sent on client connect, after add/remove/getSources, and when a source first
delivers its CONFIG.

### `config`

```json
{"type":"config","sourceId":"s1","publishMode":0,"signals":[
  {"name":"Sine","typeCode":10,"quantType":0,"numDimensions":0,
   "numRows":1,"numCols":1,"rangeMin":-1.0,"rangeMax":1.0,
   "timeMode":0,"samplingRate":5000000.0,"timeSignalIdx":-1,"unit":"V"}]}
```
Field semantics follow the UDPS signal descriptor ([Protocol.md](Protocol.md)).
`numElements = numRows × numCols`.

### `stats`

Sent at `StatsRate` Hz (default 1 Hz):

```json
{"type":"stats","sources":{"s1":{
  "state":"streaming","totalReceived":1234,"totalLost":0,
  "rateHz":100.1,"rateStdHz":0.3,
  "fragsPerCycle":3.0,"bytesPerCycle":4200.0,
  "cycleAvgMs":10.0,"cycleStdMs":0.1,"cycleMinMs":9.8,"cycleMaxMs":10.4,
  "cycleHistMin":9.8,"cycleHistMax":10.4,"cycleHist":[0,1,5,"…(20 bins)"]}}}
```

### `triggerState`

```json
{"type":"triggerState","state":"triggered","mode":"normal",
 "stopped":false,"trigTime":1765370000.1234567}
```
`state` ∈ `idle | armed | collecting | triggered`; `trigTime` present once a
trigger has fired.

### `zoom` (reply)

```json
{"type":"zoom","reqId":17,"signals":{
  "s1:Sine":{"t":[1765370000.1230000,"…"],"v":[0.123456789,"…"]}}}
```
`t` is serialised with `%.17g` (full float64 precision — required for
µs windows at Unix-epoch magnitudes), `v` with `%.9g`.

### `historyInfo`

Sent on client connect (if history is enabled) and on `historyInfo` command:

```json
{"type":"historyInfo","enabled":true,"durationHours":1.0,"decimation":10,
 "signals":{
   "scalar:Sine1":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000},
   "scalar:Sine2":{"t0":1765360000.0,"t1":1765370000.0,"count":360000,"capacity":360000}}}
```
- `enabled` — `true` if the `+History` config block is present and valid.
- `durationHours` — configured history duration.
- `decimation` — samples-to-disk decimation factor (1 = every sample).
- `signals` — per-signal metadata keyed by `"sourceId:signalName"`:
  - `t0`/`t1` — oldest/newest timestamp stored on disk (Unix seconds).
  - `count` — number of valid entries currently in the circular file.
  - `capacity` — total capacity of the circular file.

### `historyZoom` (reply)

Same shape as `zoom` reply, but `"type":"historyZoom"`:

```json
{"type":"historyZoom","reqId":42,"signals":{
  "s1:Sine":{"t":[1765360000.1230000,"…"],"v":[0.123456789,"…"]}}}
```
If history is not enabled: `{"type":"historyZoom","error":"history not enabled"}`.

### `maxPointsUpdated`

```json
{"type":"maxPointsUpdated","maxPoints":50000}
```

---

## 3. Binary frames (hub → client)

The first byte of every binary WS frame is a **version** discriminator.

### Version 1 — data push

Sent at `PushRate` Hz per source. Contains **only the samples that are new
since the previous push** (per-signal cursors hub-side), LTTB-decimated to at
most `MaxPushPoints` (default 50) per signal.

```
[1]   version = 1
[1]   sourceIdLen (L)
[L]   sourceId (UTF-8, no NUL)
[4]   numSignals (uint32)

per signal:
  [2]   keyLen (uint16) (K)
  [K]   key = signal name; "name[i]" per element for multi-element PACKET signals
  [4]   pairCount (uint32) (N)
  [N×8] t (float64, Unix seconds)
  [N×8] v (float64, physical units)
```

Clients must append samples verbatim — there is no overlap between pushes.

### Version 2 — trigger capture

Broadcast once per capture (FSM COLLECTING → TRIGGERED). Contains *all*
signals over `[trigTime − preSec, trigTime + postSec]`, each LTTB-decimated
to ≤ 20 000 points.

```
[1]   version = 2
[8]   trigTime (float64, Unix seconds)
[8]   preSec   (float64)
[8]   postSec  (float64)
[4]   numSignals (uint32)

per signal:
  [2]   keyLen (uint16) (K)
  [K]   fullKey = "src:sig" (UTF-8)
  [4]   pairCount (uint32) (N)
  [N×8] t (float64, Unix seconds)
  [N×8] v (float64)
```

---

## 4. Limits

| Limit | Value |
|-------|-------|
| Concurrent WS clients | 16 |
| UDPS source sessions | 32 |
| Max received WS payload | 64 KiB |
| Max sent WS payload | 4 MiB |
