Status: Draft
Scope: Minimal WebSocket live-telemetry path for metering in fm-rds-tx
Add the smallest practical live metering transport on top of the existing snapshot system.
The target is:
GET /measurementsGET /ws/telemetrymeasurement/measurementsThis is WS-1, not the final telemetry system.
The current codebase already provides the most important semantic building blocks:
internal/offline/generator.go
MeasurementSnapshotlatestMeasurementinternal/app/engine.go
EngineStatsinternal/control/control.go
GET /measurementsinternal/control/ui.html
/measurementsThat means WS-1 does not need a new meter model. It only needs a new live transport path.
There must be only one latest measurement truth:
/measurements exposes itWebSocket must not introduce a second, UI-specific measurement calculation path.
The producer path must never block on telemetry.
Allowed on the realtime side:
Forbidden on the realtime side:
WS-1 should include only:
GET /ws/telemetrymeasurementNo topics, bundles, runtime-event multiplexing, or speculative protocol machinery.
internal/control/telemetry.goContains:
TelemetryMessageTelemetryHubinternal/control/control.goServerGET /ws/telemetryinternal/app/engine.gocmd/fmrtx/main.gointernal/control/ui.htmlconnectTelemetryWS()/measurementsWS-1 should use a minimal typed envelope.
Example:
{
"type": "measurement",
"ts": "2026-04-13T07:00:53.842Z",
"seq": 128,
"data": {
"timestamp": "2026-04-13T07:00:53.842Z",
"sampleRateHz": 228000,
"chunkSamples": 11400,
"chunkDurationMs": 50,
"sequence": 128,
"flags": {
"stereoEnabled": true,
"stereoMode": "DSB"
},
"lrPreEncodePostWatermark": {
"lRms": 0.27,
"rRms": 0.27,
"lPeakAbs": 0.51,
"rPeakAbs": 0.51
},
"compositeFinalPreIq": {
"peakAbs": 0.63,
"pilotInjectionEquivalentPercent": 9.0
}
}
}
Rule:
data should be semantically identical to /measurements.measurementtypetsseqThe hub should:
A small subscriber model is enough for WS-1.
Conceptually:
type TelemetryMessage struct {
Type string `json:"type"`
TS time.Time `json:"ts"`
Seq uint64 `json:"seq"`
Data interface{} `json:"data"`
}
type telemetrySubscriber struct {
ch chan TelemetryMessage
}
type TelemetryHub struct {
mu sync.Mutex
subscribers map[*telemetrySubscriber]struct{}
}
Per client:
1 or 2In short:
The realtime path should not write WebSocket frames directly.
Preferred path:
That keeps transport outside the hot path.
Add a small hook to Engine, conceptually like:
SetMeasurementPublisher(func(*offpkg.MeasurementSnapshot))
The engine should publish only when:
The generator is still the source of truth.
But the engine is a good place to bridge from:
because it already sits between signal generation and external control/runtime reporting.
On WebSocket connect, the server should:
This avoids the UI showing an empty state until the next natural update arrives.
GET /ws/telemetryAdditional safety rule:
The browser flow should be:
GET /measurementsConceptually:
S.telemetry = {
ws: null,
wsConnected: false,
wsRetryTimer: null,
snapshotPollingActive: true
}
Current UI behavior polls /measurements aggressively.
WS-1 should change that to:
/measurements polling/runtime and /config polling as needed/measurements pollingThis preserves /measurements without turning it into a redundant live-stream duplicate.
WS-1 logging should stay minimal.
Useful:
Not useful:
Avoid.
Use deadlines and disconnect.
/measurements and WSPrevent by using the same underlying snapshot source.
Avoid topics, bundles, and speculative protocol work.
Create internal/control/telemetry.go with:
TelemetryMessageTelemetryHubAdd telemetry hub ownership to Server in internal/control/control.go.
Add GET /ws/telemetry handler in control.
Add measurement publisher hook to Engine.
Wire engine publisher to telemetry hub in cmd/fmrtx/main.go.
Update internal/control/ui.html:
Test behavior manually:
Implement WS-1 in the smallest possible form.
That means:
/measurementsThat gives fm-rds-tx a real live-metering transport backbone without turning the first step into a giant telemetry framework.