Initial commit: PilotVault multi-service project

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>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-13 11:43:33 +02:00
commit afc6952eda
172 files changed
+24591

No files matched your search

+376
View File
@@ -0,0 +1,376 @@
// 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
}
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package hub
import "encoding/json"
// Telemetry holds the latest flight-controller / battery values for a device.
// Pointers distinguish "not yet reported" (nil) from a genuine zero value.
type Telemetry struct {
SatelliteCount *int `json:"satelliteCount,omitempty"`
IsFlying *bool `json:"isFlying,omitempty"`
FlightMode *string `json:"flightMode,omitempty"`
Altitude *float64 `json:"altitude,omitempty"`
Latitude *float64 `json:"latitude,omitempty"`
Longitude *float64 `json:"longitude,omitempty"`
VelocityX *float64 `json:"velocityX,omitempty"`
VelocityY *float64 `json:"velocityY,omitempty"`
VelocityZ *float64 `json:"velocityZ,omitempty"`
BatteryPercent *int `json:"batteryPercent,omitempty"`
}
// DeviceState is the server's aggregated view of one app/drone.
type DeviceState struct {
DeviceID string `json:"deviceId"`
Online bool `json:"online"` // app's websocket is connected to the server
Connected bool `json:"connected"` // a drone is connected to the app
Model string `json:"model"`
Registration string `json:"registration"`
Telemetry Telemetry `json:"telemetry"`
LastSeenMs int64 `json:"lastSeenMs"`
}
// TrackPoint is one sample of the drone's GPS track (for the map trail).
type TrackPoint struct {
Lat float64 `json:"lat"`
Lng float64 `json:"lng"`
Alt float64 `json:"alt"`
TS int64 `json:"ts"`
}
// ServerToUI is the message a dashboard receives over /ws/ui.
type ServerToUI struct {
Type string `json:"type"` // "snapshot" | "update" | "removed"
Device *DeviceState `json:"device,omitempty"`
Devices []*DeviceState `json:"devices,omitempty"`
DeviceID string `json:"deviceId,omitempty"` // for "removed"
Event map[string]any `json:"event,omitempty"` // the raw device event that triggered this
TS int64 `json:"ts"`
}
// Command is what the server pushes down to a device over /ws/device.
type Command struct {
Type string `json:"type"` // always "command"
Command string `json:"command"`
Payload map[string]any `json:"payload,omitempty"`
TS int64 `json:"ts"`
}
// toFloat coerces a JSON-decoded value into a float64.
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case float64:
return n, true
case float32:
return float64(n), true
case int:
return float64(n), true
case int64:
return float64(n), true
case json.Number:
f, err := n.Float64()
return f, err == nil
}
return 0, false
}
// toInt coerces a JSON-decoded value into an int.
func toInt(v any) (int, bool) {
if f, ok := toFloat(v); ok {
return int(f), true
}
return 0, false
}
// applyTelemetry copies any present telemetry fields from a raw event map.
func applyTelemetry(t *Telemetry, raw map[string]any) {
if v, ok := toInt(raw["satelliteCount"]); ok {
t.SatelliteCount = &v
}
if v, ok := raw["isFlying"].(bool); ok {
t.IsFlying = &v
}
if v, ok := raw["flightMode"].(string); ok {
t.FlightMode = &v
}
if v, ok := toFloat(raw["altitude"]); ok {
t.Altitude = &v
}
if v, ok := toFloat(raw["latitude"]); ok {
t.Latitude = &v
}
if v, ok := toFloat(raw["longitude"]); ok {
t.Longitude = &v
}
if v, ok := toFloat(raw["velocityX"]); ok {
t.VelocityX = &v
}
if v, ok := toFloat(raw["velocityY"]); ok {
t.VelocityY = &v
}
if v, ok := toFloat(raw["velocityZ"]); ok {
t.VelocityZ = &v
}
}