Phase 2/4 done, working on phase 3/5

This commit is contained in:
Martino Ferrari
2026-04-24 15:46:04 +02:00
parent 9aa89cc0cf
commit 8b548ba1c2
43 changed files with 6800 additions and 156 deletions
+383
View File
@@ -0,0 +1,383 @@
package server
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/uopi/uopi/internal/broker"
"github.com/uopi/uopi/internal/datasource"
)
// ── Incoming message types ────────────────────────────────────────────────────
type inMsg struct {
Type string `json:"type"`
// subscribe / unsubscribe
Signals []sigRef `json:"signals,omitempty"`
// write
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
// history
Start time.Time `json:"start"`
End time.Time `json:"end"`
MaxPoints int `json:"maxPoints,omitempty"`
}
type sigRef struct {
DS string `json:"ds"`
Name string `json:"name"`
}
// ── Outgoing message types ────────────────────────────────────────────────────
type outMsg struct {
Type string `json:"type"`
// update
DS string `json:"ds,omitempty"`
Name string `json:"name,omitempty"`
TS string `json:"ts,omitempty"`
Value any `json:"value,omitempty"`
Quality string `json:"quality,omitempty"`
// meta
Meta *metaPayload `json:"meta,omitempty"`
// history
Points []histPoint `json:"points,omitempty"`
// error
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type metaPayload struct {
Type string `json:"type"`
Unit string `json:"unit,omitempty"`
Description string `json:"description,omitempty"`
DisplayLow float64 `json:"displayLow"`
DisplayHigh float64 `json:"displayHigh"`
DriveLow float64 `json:"driveLow,omitempty"`
DriveHigh float64 `json:"driveHigh,omitempty"`
EnumStrings []string `json:"enumStrings,omitempty"`
Writable bool `json:"writable"`
}
type histPoint struct {
TS string `json:"ts"`
Value any `json:"value"`
}
// ── Handler ───────────────────────────────────────────────────────────────────
type wsHandler struct {
broker *broker.Broker
log *slog.Logger
}
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
// Permissive origin check for LAN / SSH-tunnel use; tighten when auth lands.
InsecureSkipVerify: true,
})
if err != nil {
h.log.Error("ws accept failed", "err", err)
return
}
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
c := &wsClient{
conn: conn,
broker: h.broker,
outCh: make(chan []byte, 128),
updateCh: make(chan broker.Update, 256),
subs: make(map[broker.SignalRef]func()),
log: h.log,
}
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done(); c.readLoop(ctx, cancel) }()
go func() { defer wg.Done(); c.dispatchLoop(ctx) }()
go func() { defer wg.Done(); c.writeLoop(ctx) }()
wg.Wait()
// Cancel all subscriptions on disconnect.
c.subsMu.Lock()
for _, cancelSub := range c.subs {
cancelSub()
}
c.subsMu.Unlock()
_ = conn.Close(websocket.StatusNormalClosure, "")
}
// ── wsClient ──────────────────────────────────────────────────────────────────
type wsClient struct {
conn *websocket.Conn
broker *broker.Broker
log *slog.Logger
outCh chan []byte // serialised outgoing messages
updateCh chan broker.Update // raw updates from the broker
subsMu sync.Mutex
subs map[broker.SignalRef]func() // ref → cancel
}
// readLoop reads incoming WebSocket messages and dispatches them.
// It cancels ctx (and therefore the other goroutines) on any error.
func (c *wsClient) readLoop(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
for {
_, data, err := c.conn.Read(ctx)
if err != nil {
if !errors.Is(err, context.Canceled) {
c.log.Debug("ws read", "err", err)
}
return
}
c.handleMessage(ctx, data)
}
}
// dispatchLoop converts broker updates to JSON and enqueues them for writing.
func (c *wsClient) dispatchLoop(ctx context.Context) {
for {
select {
case u := <-c.updateCh:
msg := outMsg{
Type: "update",
DS: u.Ref.DS,
Name: u.Ref.Name,
TS: u.Value.Timestamp.UTC().Format(time.RFC3339Nano),
Value: u.Value.Data,
Quality: u.Value.Quality.String(),
}
b, err := json.Marshal(msg)
if err != nil {
continue
}
select {
case c.outCh <- b:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}
// writeLoop sends queued messages over the WebSocket connection.
func (c *wsClient) writeLoop(ctx context.Context) {
for {
select {
case msg := <-c.outCh:
if err := c.conn.Write(ctx, websocket.MessageText, msg); err != nil {
return
}
case <-ctx.Done():
return
}
}
}
// handleMessage parses and routes a single incoming client message.
func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
var msg inMsg
if err := json.Unmarshal(data, &msg); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid JSON: "+err.Error())
return
}
switch msg.Type {
case "subscribe":
c.handleSubscribe(ctx, msg.Signals)
case "unsubscribe":
c.handleUnsubscribe(msg.Signals)
case "write":
c.handleWrite(ctx, msg)
case "history":
c.handleHistory(ctx, msg)
default:
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
}
}
func (c *wsClient) handleSubscribe(ctx context.Context, refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
_, already := c.subs[ref]
c.subsMu.Unlock()
if already {
continue
}
cancelSub, err := c.broker.Subscribe(ref, c.updateCh)
if err != nil {
c.sendError(ctx, "SUBSCRIBE_ERROR", err.Error())
continue
}
c.subsMu.Lock()
c.subs[ref] = cancelSub
c.subsMu.Unlock()
// Send metadata once on subscribe.
c.sendMeta(ctx, ref)
}
}
func (c *wsClient) handleUnsubscribe(refs []sigRef) {
for _, r := range refs {
ref := broker.SignalRef{DS: r.DS, Name: r.Name}
c.subsMu.Lock()
if cancel, ok := c.subs[ref]; ok {
cancel()
delete(c.subs, ref)
}
c.subsMu.Unlock()
}
}
func (c *wsClient) handleWrite(ctx context.Context, msg inMsg) {
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
// Decode the JSON value as the appropriate Go type.
var value any
if err := json.Unmarshal(msg.Value, &value); err != nil {
c.sendError(ctx, "PARSE_ERROR", "invalid value: "+err.Error())
return
}
if err := ds.Write(ctx, msg.Name, value); err != nil {
c.sendError(ctx, "WRITE_ERROR", err.Error())
}
}
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
ds, ok := c.broker.Source(msg.DS)
if !ok {
c.sendError(ctx, "NOT_FOUND", "unknown data source: "+msg.DS)
return
}
maxPts := msg.MaxPoints
if maxPts <= 0 {
maxPts = 5000
}
vals, err := ds.History(ctx, msg.Name, msg.Start, msg.End, maxPts)
if err != nil {
c.sendError(ctx, "HISTORY_ERROR", err.Error())
return
}
pts := make([]histPoint, len(vals))
for i, v := range vals {
pts[i] = histPoint{
TS: v.Timestamp.UTC().Format(time.RFC3339Nano),
Value: v.Data,
}
}
resp := outMsg{
Type: "history",
DS: msg.DS,
Name: msg.Name,
Points: pts,
}
b, _ := json.Marshal(resp)
select {
case c.outCh <- b:
default:
}
}
func (c *wsClient) sendMeta(ctx context.Context, ref broker.SignalRef) {
ds, ok := c.broker.Source(ref.DS)
if !ok {
return
}
meta, err := ds.GetMetadata(ctx, ref.Name)
if err != nil {
return
}
msg := outMsg{
Type: "meta",
DS: ref.DS,
Name: ref.Name,
Meta: metadataToPayload(meta),
}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
default:
}
}
func (c *wsClient) sendError(ctx context.Context, code, message string) {
msg := outMsg{Type: "error", Code: code, Message: message}
b, _ := json.Marshal(msg)
select {
case c.outCh <- b:
case <-ctx.Done():
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
func metadataToPayload(m datasource.Metadata) *metaPayload {
return &metaPayload{
Type: dataTypeName(m.Type),
Unit: m.Unit,
Description: m.Description,
DisplayLow: m.DisplayLow,
DisplayHigh: m.DisplayHigh,
DriveLow: m.DriveLow,
DriveHigh: m.DriveHigh,
EnumStrings: m.EnumStrings,
Writable: m.Writable,
}
}
func dataTypeName(t datasource.DataType) string {
switch t {
case datasource.TypeFloat64:
return "float64"
case datasource.TypeFloat64Array:
return "float64[]"
case datasource.TypeString:
return "string"
case datasource.TypeInt64:
return "int64"
case datasource.TypeBool:
return "bool"
case datasource.TypeEnum:
return "enum"
default:
return "unknown"
}
}