2026-05-18 17:43:46 -04:00
|
|
|
// Package main is the entry point for the RemoteRig central hub.
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-18 17:47:06 -04:00
|
|
|
"context"
|
2026-05-21 21:39:07 +00:00
|
|
|
"embed"
|
2026-05-18 17:43:46 -04:00
|
|
|
"fmt"
|
2026-05-21 21:39:07 +00:00
|
|
|
"io/fs"
|
2026-05-18 17:43:46 -04:00
|
|
|
"log"
|
2026-05-18 17:47:06 -04:00
|
|
|
"net/http"
|
2026-05-18 17:43:46 -04:00
|
|
|
"os"
|
2026-05-18 17:47:06 -04:00
|
|
|
"os/signal"
|
|
|
|
|
"syscall"
|
2026-05-18 17:43:46 -04:00
|
|
|
"time"
|
|
|
|
|
|
2026-05-18 17:52:48 -04:00
|
|
|
"github.com/cubecraft/remoterig/internal/api"
|
2026-05-18 17:47:06 -04:00
|
|
|
"github.com/cubecraft/remoterig/internal/auth"
|
|
|
|
|
"github.com/cubecraft/remoterig/internal/db"
|
2026-05-18 17:52:48 -04:00
|
|
|
"github.com/cubecraft/remoterig/internal/events"
|
2026-05-21 21:16:08 +00:00
|
|
|
"github.com/cubecraft/remoterig/internal/mqtt"
|
2026-05-18 17:47:06 -04:00
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/go-chi/chi/v5/middleware"
|
2026-05-18 17:43:46 -04:00
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-21 21:39:07 +00:00
|
|
|
//go:embed all:src/dist
|
|
|
|
|
var frontendFS embed.FS
|
|
|
|
|
|
2026-05-18 17:43:46 -04:00
|
|
|
// Config holds the application configuration.
|
|
|
|
|
type Config struct {
|
|
|
|
|
DBPath string `yaml:"db_path"`
|
|
|
|
|
APIKey string `yaml:"api_key"`
|
|
|
|
|
Port string `yaml:"port"`
|
|
|
|
|
ReadTimeout time.Duration `yaml:"read_timeout"`
|
|
|
|
|
WriteTimeout time.Duration `yaml:"write_timeout"`
|
|
|
|
|
IdleTimeout time.Duration `yaml:"idle_timeout"`
|
|
|
|
|
MQTT struct {
|
|
|
|
|
Broker string `yaml:"broker"`
|
|
|
|
|
ClientID string `yaml:"client_id"`
|
|
|
|
|
} `yaml:"mqtt"`
|
|
|
|
|
Platform struct {
|
|
|
|
|
Type string `yaml:"type"`
|
|
|
|
|
MaxCameras int `yaml:"max_cameras"`
|
|
|
|
|
} `yaml:"platform"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func main() {
|
|
|
|
|
log.Println("RemoteRig hub starting...")
|
|
|
|
|
|
|
|
|
|
// Load config
|
|
|
|
|
cfg, err := loadConfig("config.yaml")
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to load config: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 17:47:06 -04:00
|
|
|
// Open database
|
2026-05-18 17:52:48 -04:00
|
|
|
sqlDB, err := db.Open(cfg.DBPath)
|
2026-05-18 17:47:06 -04:00
|
|
|
if err != nil {
|
|
|
|
|
log.Fatalf("Failed to open database: %v", err)
|
|
|
|
|
}
|
2026-05-18 17:52:48 -04:00
|
|
|
defer sqlDB.Close()
|
2026-05-18 17:47:06 -04:00
|
|
|
log.Printf("Database open: %s", cfg.DBPath)
|
2026-05-18 17:43:46 -04:00
|
|
|
|
2026-05-18 17:52:48 -04:00
|
|
|
// Create SSE hub for real-time updates
|
|
|
|
|
sseHub := events.NewHub()
|
|
|
|
|
|
2026-05-21 21:16:08 +00:00
|
|
|
// Start MQTT subscriber for ESP32 camera status ingestion
|
|
|
|
|
mqttSub := mqtt.NewSubscriber(cfg.MQTT.Broker, cfg.MQTT.ClientID, sqlDB, sseHub)
|
|
|
|
|
if err := mqttSub.Connect(); err != nil {
|
|
|
|
|
log.Printf("WARNING: MQTT subscriber failed to connect: %v (running without MQTT)", err)
|
|
|
|
|
}
|
|
|
|
|
defer mqttSub.Close()
|
|
|
|
|
|
2026-05-18 17:47:06 -04:00
|
|
|
// Set up router
|
|
|
|
|
r := chi.NewRouter()
|
|
|
|
|
r.Use(middleware.RequestID)
|
|
|
|
|
r.Use(middleware.RealIP)
|
|
|
|
|
r.Use(middleware.Logger)
|
|
|
|
|
r.Use(middleware.Recoverer)
|
|
|
|
|
r.Use(middleware.Timeout(cfg.WriteTimeout))
|
|
|
|
|
|
|
|
|
|
// Health check (no auth)
|
|
|
|
|
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
|
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// API routes (auth required if API key is configured)
|
2026-05-18 17:52:48 -04:00
|
|
|
r.Mount("/api/v1", auth.Middleware(cfg.APIKey)(apiRouter(sseHub, sqlDB)))
|
2026-05-18 17:47:06 -04:00
|
|
|
|
2026-05-21 21:39:07 +00:00
|
|
|
// Serve embedded React frontend with SPA fallback
|
|
|
|
|
r.Mount("/", frontendHandler())
|
|
|
|
|
|
2026-05-18 17:47:06 -04:00
|
|
|
// Create server
|
|
|
|
|
httpServer := &http.Server{
|
|
|
|
|
Addr: ":" + cfg.Port,
|
|
|
|
|
Handler: r,
|
|
|
|
|
ReadTimeout: cfg.ReadTimeout,
|
|
|
|
|
WriteTimeout: cfg.WriteTimeout,
|
|
|
|
|
IdleTimeout: cfg.IdleTimeout,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Graceful shutdown
|
|
|
|
|
go func() {
|
|
|
|
|
sigInt := make(chan os.Signal, 1)
|
|
|
|
|
signal.Notify(sigInt, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
|
<-sigInt
|
|
|
|
|
log.Println("Shutting down server...")
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
httpServer.Shutdown(ctx)
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
log.Printf("Server listening on port %s", cfg.Port)
|
|
|
|
|
if err := httpServer.ListenAndServe(); err != nil {
|
|
|
|
|
log.Fatalf("Server failed: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// apiRouter creates the API route tree.
|
2026-05-18 17:52:48 -04:00
|
|
|
func apiRouter(sseHub *events.Hub, database *db.DB) http.Handler {
|
2026-05-18 17:47:06 -04:00
|
|
|
r := chi.NewRouter()
|
2026-05-18 17:52:48 -04:00
|
|
|
|
|
|
|
|
// Camera management routes
|
|
|
|
|
r.Get("/cameras", api.ListCameras(database))
|
|
|
|
|
r.Post("/cameras", api.RegisterCamera(database))
|
|
|
|
|
r.Get("/cameras/{id}", api.GetCameraDetail(database))
|
|
|
|
|
|
|
|
|
|
// Recording control routes
|
|
|
|
|
r.Post("/cameras/{id}/start", api.StartRecording(database))
|
|
|
|
|
r.Post("/cameras/{id}/stop", api.StopRecording(database))
|
|
|
|
|
|
|
|
|
|
// Status ingestion (from ESP32 nodes)
|
|
|
|
|
r.Post("/cameras/{id}/status", api.PushStatus(database))
|
|
|
|
|
|
|
|
|
|
// Real-time events (SSE)
|
|
|
|
|
r.Handle("/events/stream", sseHub.Handler())
|
|
|
|
|
|
2026-05-18 17:47:06 -04:00
|
|
|
return r
|
2026-05-18 17:43:46 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadConfig(path string) (*Config, error) {
|
|
|
|
|
data, err := os.ReadFile(path)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("read config file: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var cfg Config
|
|
|
|
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("parse config: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Defaults
|
|
|
|
|
if cfg.Port == "" {
|
|
|
|
|
cfg.Port = "8080"
|
|
|
|
|
}
|
|
|
|
|
if cfg.DBPath == "" {
|
|
|
|
|
cfg.DBPath = "remoterig.db"
|
|
|
|
|
}
|
|
|
|
|
if cfg.MQTT.Broker == "" {
|
|
|
|
|
cfg.MQTT.Broker = "localhost:1883"
|
|
|
|
|
}
|
|
|
|
|
if cfg.MQTT.ClientID == "" {
|
|
|
|
|
cfg.MQTT.ClientID = "remoterig-hub"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return &cfg, nil
|
2026-05-21 21:39:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// frontendHandler returns an http.Handler that serves the embedded React
|
|
|
|
|
// frontend from src/dist/ with SPA-style fallback: any path that doesn't
|
|
|
|
|
// match a static file serves index.html for client-side routing.
|
|
|
|
|
//
|
|
|
|
|
// The frontend is embedded via //go:embed all:src/dist at build time.
|
|
|
|
|
// If src/dist/ is empty or missing at build time, the embedded fallback
|
|
|
|
|
// index.html (committed to the repo) is served instead, showing a
|
|
|
|
|
// "run npm run build" message.
|
|
|
|
|
func frontendHandler() http.Handler {
|
|
|
|
|
distFS, err := fs.Sub(frontendFS, "src/dist")
|
|
|
|
|
if err != nil {
|
|
|
|
|
// Shouldn't happen if embed worked, but be defensive.
|
|
|
|
|
panic("embedded frontend filesystem not found: " + err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fileServer := http.FileServer(http.FS(distFS))
|
|
|
|
|
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
// Try to serve the requested file.
|
|
|
|
|
f, err := distFS.Open(r.URL.Path[1:]) // strip leading "/"
|
|
|
|
|
if err != nil {
|
|
|
|
|
// File not found — serve index.html for SPA routing.
|
|
|
|
|
r.URL.Path = "/"
|
|
|
|
|
fileServer.ServeHTTP(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
f.Close()
|
|
|
|
|
|
|
|
|
|
// File exists, serve it.
|
|
|
|
|
fileServer.ServeHTTP(w, r)
|
|
|
|
|
})
|
2026-05-18 17:52:48 -04:00
|
|
|
}
|