Phase 2/4 done, working on phase 3/5
This commit is contained in:
@@ -6,17 +6,20 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
const apiPrefix = "/api/v1"
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an HTTP server that serves the embedded frontend and a health
|
||||
// check endpoint. Additional routes (API, WebSocket) will be registered in
|
||||
// later phases.
|
||||
func New(addr string, webFS fs.FS, log *slog.Logger) *Server {
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, log *slog.Logger) *Server {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Health check
|
||||
@@ -25,7 +28,13 @@ func New(addr string, webFS fs.FS, log *slog.Logger) *Server {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
|
||||
// Serve embedded frontend for everything else
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log})
|
||||
|
||||
// REST API
|
||||
api.New(brk, log).Register(mux, apiPrefix)
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
|
||||
return &Server{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user