Add API Server (Go/PocketBase), Web App (Go BFF + Vue), Fly App (Flutter/DJI MSDK), Adobe Plugin, and Docker/Docker AIO deployment configs. Design assets and build artifacts are gitignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
377 lines
9.7 KiB
Go
377 lines
9.7 KiB
Go
// Package hub keeps the live, in-memory view of every connected device and
|
|
// fans telemetry out to dashboards over websockets. It is the drone-domain core
|
|
// of the API Server; the api package exposes it over HTTP.
|
|
package hub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
writeWait = 10 * time.Second
|
|
pongWait = 60 * time.Second
|
|
pingPeriod = (pongWait * 9) / 10
|
|
maxMessageSize = 1 << 20
|
|
sendBuffer = 256
|
|
maxTrackPoints = 1000
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 1024,
|
|
WriteBufferSize: 1024,
|
|
// Dev default: accept any origin. Lock this down for production.
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
type clientKind int
|
|
|
|
const (
|
|
kindDevice clientKind = iota
|
|
kindUI
|
|
)
|
|
|
|
// Client is a single websocket connection (either a device/app or a dashboard).
|
|
type Client struct {
|
|
hub *Hub
|
|
conn *websocket.Conn
|
|
send chan []byte
|
|
kind clientKind
|
|
deviceID string
|
|
}
|
|
|
|
// Hub keeps track of all connections and the latest state per device.
|
|
type Hub struct {
|
|
mu sync.RWMutex
|
|
uis map[*Client]bool
|
|
devices map[string]*Client // currently-online device connections
|
|
states map[string]*DeviceState // last-known state, persists across reconnects
|
|
tracks map[string][]TrackPoint
|
|
}
|
|
|
|
// New constructs an empty Hub.
|
|
func New() *Hub {
|
|
return &Hub{
|
|
uis: make(map[*Client]bool),
|
|
devices: make(map[string]*Client),
|
|
states: make(map[string]*DeviceState),
|
|
tracks: make(map[string][]TrackPoint),
|
|
}
|
|
}
|
|
|
|
func nowMs() int64 { return time.Now().UnixMilli() }
|
|
|
|
// ── Websocket entry points ───────────────────────────────────────────────────
|
|
|
|
// ServeDevice upgrades an incoming request into a device connection (the Fly
|
|
// App's telemetry uplink) bound to deviceID.
|
|
func (h *Hub) ServeDevice(w http.ResponseWriter, r *http.Request, deviceID string) {
|
|
if deviceID == "" {
|
|
deviceID = "default"
|
|
}
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindDevice, deviceID: deviceID}
|
|
h.addDevice(c)
|
|
log.Printf("device connected: %s", deviceID)
|
|
go c.writePump()
|
|
go c.readPump()
|
|
}
|
|
|
|
// ServeUI upgrades an incoming request into a dashboard connection.
|
|
func (h *Hub) ServeUI(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
c := &Client{hub: h, conn: conn, send: make(chan []byte, sendBuffer), kind: kindUI}
|
|
h.addUI(c)
|
|
go c.writePump()
|
|
go c.readPump()
|
|
}
|
|
|
|
// ── UI client lifecycle ──────────────────────────────────────────────────────
|
|
|
|
func (h *Hub) addUI(c *Client) {
|
|
h.mu.Lock()
|
|
h.uis[c] = true
|
|
devices := make([]*DeviceState, 0, len(h.states))
|
|
for _, s := range h.states {
|
|
cp := *s
|
|
devices = append(devices, &cp)
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
if msg, err := json.Marshal(ServerToUI{Type: "snapshot", Devices: devices, TS: nowMs()}); err == nil {
|
|
c.send <- msg
|
|
}
|
|
}
|
|
|
|
func (h *Hub) removeUI(c *Client) {
|
|
h.mu.Lock()
|
|
delete(h.uis, c)
|
|
h.mu.Unlock()
|
|
}
|
|
|
|
// ── Device client lifecycle ──────────────────────────────────────────────────
|
|
|
|
func (h *Hub) addDevice(c *Client) {
|
|
h.mu.Lock()
|
|
h.devices[c.deviceID] = c
|
|
s := h.states[c.deviceID]
|
|
if s == nil {
|
|
s = &DeviceState{DeviceID: c.deviceID}
|
|
h.states[c.deviceID] = s
|
|
}
|
|
s.Online = true
|
|
s.LastSeenMs = nowMs()
|
|
snap := *s
|
|
h.mu.Unlock()
|
|
|
|
h.broadcastUI(ServerToUI{Type: "update", Device: &snap, TS: nowMs()})
|
|
}
|
|
|
|
func (h *Hub) removeDevice(c *Client) {
|
|
h.mu.Lock()
|
|
if h.devices[c.deviceID] == c {
|
|
delete(h.devices, c.deviceID)
|
|
}
|
|
var snap *DeviceState
|
|
if s := h.states[c.deviceID]; s != nil {
|
|
s.Online = false
|
|
s.Connected = false
|
|
s.Telemetry = Telemetry{} // app stopped streaming: drop stale live telemetry
|
|
s.LastSeenMs = nowMs()
|
|
cp := *s
|
|
snap = &cp
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
if snap != nil {
|
|
h.broadcastUI(ServerToUI{Type: "update", Device: snap, TS: nowMs()})
|
|
}
|
|
}
|
|
|
|
// ── Data flow ────────────────────────────────────────────────────────────────
|
|
|
|
// Ingest applies a raw event from a device and fans it out to the dashboards.
|
|
func (h *Hub) Ingest(deviceID string, raw map[string]any) {
|
|
h.mu.Lock()
|
|
s := h.states[deviceID]
|
|
if s == nil {
|
|
s = &DeviceState{DeviceID: deviceID}
|
|
h.states[deviceID] = s
|
|
}
|
|
s.Online = true
|
|
s.LastSeenMs = nowMs()
|
|
|
|
switch raw["type"] {
|
|
case "registration":
|
|
if st, ok := raw["state"].(string); ok {
|
|
s.Registration = st
|
|
}
|
|
case "connection":
|
|
if c, ok := raw["connected"].(bool); ok {
|
|
s.Connected = c
|
|
if !c {
|
|
s.Telemetry = Telemetry{} // drone unlinked: live telemetry is no longer valid
|
|
}
|
|
}
|
|
if m, ok := raw["model"].(string); ok {
|
|
s.Model = m
|
|
}
|
|
case "battery":
|
|
if p, ok := toInt(raw["percent"]); ok {
|
|
s.Telemetry.BatteryPercent = &p
|
|
}
|
|
case "telemetry":
|
|
applyTelemetry(&s.Telemetry, raw)
|
|
lat, okLat := toFloat(raw["latitude"])
|
|
lng, okLng := toFloat(raw["longitude"])
|
|
if okLat && okLng && (lat != 0 || lng != 0) {
|
|
alt, _ := toFloat(raw["altitude"])
|
|
h.appendTrackLocked(deviceID, TrackPoint{Lat: lat, Lng: lng, Alt: alt, TS: nowMs()})
|
|
}
|
|
}
|
|
snap := *s
|
|
h.mu.Unlock()
|
|
|
|
h.broadcastUI(ServerToUI{Type: "update", Device: &snap, Event: raw, TS: nowMs()})
|
|
}
|
|
|
|
// appendTrackLocked must be called with h.mu held.
|
|
func (h *Hub) appendTrackLocked(deviceID string, p TrackPoint) {
|
|
t := append(h.tracks[deviceID], p)
|
|
if len(t) > maxTrackPoints {
|
|
t = t[len(t)-maxTrackPoints:]
|
|
}
|
|
h.tracks[deviceID] = t
|
|
}
|
|
|
|
// SendCommand routes a command from the server (or a dashboard) to a device.
|
|
// It returns false if the device is not currently connected.
|
|
func (h *Hub) SendCommand(deviceID, command string, payload map[string]any) bool {
|
|
cmd := Command{Type: "command", Command: command, Payload: payload, TS: nowMs()}
|
|
msg, err := json.Marshal(cmd)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
h.mu.RLock()
|
|
c := h.devices[deviceID]
|
|
h.mu.RUnlock()
|
|
if c == nil {
|
|
return false
|
|
}
|
|
select {
|
|
case c.send <- msg:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (h *Hub) broadcastUI(m ServerToUI) {
|
|
msg, err := json.Marshal(m)
|
|
if err != nil {
|
|
return
|
|
}
|
|
h.mu.RLock()
|
|
for c := range h.uis {
|
|
select {
|
|
case c.send <- msg:
|
|
default: // drop messages for a slow/stuck dashboard rather than block
|
|
}
|
|
}
|
|
h.mu.RUnlock()
|
|
}
|
|
|
|
// Forget drops a device's stored state and track. Intended for clearing
|
|
// stale/offline entries; a still-online device will simply repopulate.
|
|
func (h *Hub) Forget(deviceID string) bool {
|
|
h.mu.Lock()
|
|
_, existed := h.states[deviceID]
|
|
delete(h.states, deviceID)
|
|
delete(h.tracks, deviceID)
|
|
h.mu.Unlock()
|
|
if existed {
|
|
h.broadcastUI(ServerToUI{Type: "removed", DeviceID: deviceID, TS: nowMs()})
|
|
}
|
|
return existed
|
|
}
|
|
|
|
// OnlineCount returns the number of devices with a live websocket connection
|
|
// right now (offline/last-known states are not counted).
|
|
func (h *Hub) OnlineCount() int {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
return len(h.devices)
|
|
}
|
|
|
|
// Snapshot returns a copy of every known device's last state.
|
|
func (h *Hub) Snapshot() []*DeviceState {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
out := make([]*DeviceState, 0, len(h.states))
|
|
for _, s := range h.states {
|
|
cp := *s
|
|
out = append(out, &cp)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Track returns a copy of a device's GPS track.
|
|
func (h *Hub) Track(deviceID string) []TrackPoint {
|
|
h.mu.RLock()
|
|
defer h.mu.RUnlock()
|
|
src := h.tracks[deviceID]
|
|
out := make([]TrackPoint, len(src))
|
|
copy(out, src)
|
|
return out
|
|
}
|
|
|
|
// ── Pumps ────────────────────────────────────────────────────────────────────
|
|
|
|
func (c *Client) readPump() {
|
|
defer func() {
|
|
if c.kind == kindDevice {
|
|
c.hub.removeDevice(c)
|
|
} else {
|
|
c.hub.removeUI(c)
|
|
}
|
|
c.conn.Close()
|
|
}()
|
|
|
|
c.conn.SetReadLimit(maxMessageSize)
|
|
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
c.conn.SetPongHandler(func(string) error {
|
|
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
})
|
|
|
|
for {
|
|
_, data, err := c.conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
log.Printf("ws read error (%s): %v", c.deviceID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
if c.kind == kindDevice {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
continue
|
|
}
|
|
c.hub.Ingest(c.deviceID, raw)
|
|
continue
|
|
}
|
|
|
|
// UI -> server: command requests
|
|
var req struct {
|
|
Action string `json:"action"`
|
|
DeviceID string `json:"deviceId"`
|
|
Command string `json:"command"`
|
|
Payload map[string]any `json:"payload"`
|
|
}
|
|
if err := json.Unmarshal(data, &req); err != nil {
|
|
continue
|
|
}
|
|
if req.Action == "command" && req.Command != "" {
|
|
c.hub.SendCommand(req.DeviceID, req.Command, req.Payload)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) writePump() {
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer func() {
|
|
ticker.Stop()
|
|
c.conn.Close()
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case msg, ok := <-c.send:
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if !ok {
|
|
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
|
return
|
|
}
|
|
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
|
return
|
|
}
|
|
case <-ticker.C:
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|