diff --git a/examples/main.go b/examples/main.go index f1dc37c..f6e668c 100644 --- a/examples/main.go +++ b/examples/main.go @@ -2,25 +2,30 @@ package main import ( "bufio" + "flag" "fmt" "os" "os/signal" "strings" + "sync" "syscall" "time" "track-gopher/derby" + "track-gopher/web" ) func main() { - // Replace with your actual serial port - portName := "/dev/ttyACM0" - if len(os.Args) > 1 { - portName = os.Args[1] - } + // Parse command line flags + portName := flag.String("port", "/dev/ttyACM0", "Serial port for the derby clock") + baudRate := flag.Int("baud", 19200, "Baud rate for the serial port") + webPort := flag.Int("web-port", 8080, "Port for the web server") + noWeb := flag.Bool("no-web", false, "Disable web interface") + noTerminal := flag.Bool("no-terminal", false, "Disable terminal interface") + flag.Parse() // Create a new derby clock connection - clock, err := derby.NewDerbyClock(portName, 19200) + clock, err := derby.NewDerbyClock(*portName, *baudRate) if err != nil { fmt.Printf("Error opening derby clock: %v\n", err) os.Exit(1) @@ -31,10 +36,63 @@ func main() { sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + var wg sync.WaitGroup + + // Start web interface if enabled + if !*noWeb { + wg.Add(1) + go func() { + defer wg.Done() + startWebInterface(clock, *webPort, sigChan) + }() + } + + // Start terminal interface if enabled + if !*noTerminal { + wg.Add(1) + go func() { + defer wg.Done() + startTerminalInterface(clock, sigChan) + }() + } + + // Wait for signal to exit + <-sigChan + fmt.Println("Shutting down...") + time.Sleep(500 * time.Millisecond) // Give a moment for any pending operations to complete + + // Wait for all interfaces to shut down + wg.Wait() +} + +// startWebInterface initializes and runs the web interface +func startWebInterface(clock *derby.DerbyClock, webPort int, sigChan chan os.Signal) { + // Create and start the web server + server, err := web.NewServer(clock, webPort) + if err != nil { + fmt.Printf("Error creating web server: %v\n", err) + sigChan <- syscall.SIGTERM + return + } + + fmt.Printf("Web interface available at http://localhost:%d\n", webPort) + + // Start the web server + if err := server.Start(); err != nil { + fmt.Printf("Web server error: %v\n", err) + sigChan <- syscall.SIGTERM + } +} + +// startTerminalInterface initializes and runs the terminal interface +func startTerminalInterface(clock *derby.DerbyClock, sigChan chan os.Signal) { + fmt.Println("Terminal interface started") + // Reset the clock to start fresh if err := clock.Reset(); err != nil { fmt.Printf("Error resetting clock: %v\n", err) - os.Exit(1) + sigChan <- syscall.SIGTERM + return } fmt.Println("Clock reset. Ready to start race.") @@ -72,56 +130,49 @@ func main() { }() // Handle keyboard input - go func() { - reader := bufio.NewReader(os.Stdin) - for { - fmt.Print("Enter command (r/f/q/?): ") - input, err := reader.ReadString('\n') - if err != nil { - fmt.Printf("Error reading input: %v\n", err) - continue - } + reader := bufio.NewReader(os.Stdin) + for { + fmt.Print("Enter command (r/f/q/?): ") + input, err := reader.ReadString('\n') + if err != nil { + fmt.Printf("Error reading input: %v\n", err) + continue + } - // Trim whitespace and convert to lowercase - command := strings.TrimSpace(strings.ToLower(input)) + // Trim whitespace and convert to lowercase + command := strings.TrimSpace(strings.ToLower(input)) - switch command { - case "r": - fmt.Println("Resetting clock...") - if err := clock.Reset(); err != nil { - fmt.Printf("Error resetting clock: %v\n", err) - } else { - fmt.Println("Clock reset. Ready to start race.") - } + switch command { + case "r": + fmt.Println("Resetting clock...") + if err := clock.Reset(); err != nil { + fmt.Printf("Error resetting clock: %v\n", err) + } else { + fmt.Println("Clock reset. Ready to start race.") + } - case "f": - fmt.Println("Forcing race to end...") - if err := clock.ForceEnd(); err != nil { - fmt.Printf("Error forcing race end: %v\n", err) - } + case "f": + fmt.Println("Forcing race to end...") + if err := clock.ForceEnd(); err != nil { + fmt.Printf("Error forcing race end: %v\n", err) + } - case "q": - fmt.Println("Quitting...") - sigChan <- syscall.SIGTERM - return - - case "?": - fmt.Println("\nCommands:") - fmt.Println(" r - Reset the clock") - fmt.Println(" f - Force end the race") - fmt.Println(" q - Quit the program") - fmt.Println(" ? - Show this help message") - - default: - if command != "" { - fmt.Println("Unknown command. Type ? for help.") - } + case "q": + fmt.Println("Quitting...") + sigChan <- syscall.SIGTERM + return + + case "?": + fmt.Println("\nCommands:") + fmt.Println(" r - Reset the clock") + fmt.Println(" f - Force end the race") + fmt.Println(" q - Quit the program") + fmt.Println(" ? - Show this help message") + + default: + if command != "" { + fmt.Println("Unknown command. Type ? for help.") } } - }() - - // Wait for signal to exit - <-sigChan - fmt.Println("Shutting down...") - time.Sleep(500 * time.Millisecond) // Give a moment for any pending operations to complete + } } diff --git a/go.mod b/go.mod index 16b0f15..3494371 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,10 @@ module track-gopher go 1.24 -require go.bug.st/serial v1.6.2 +require ( + github.com/go-chi/chi/v5 v5.2.1 + go.bug.st/serial v1.6.2 +) require ( github.com/creack/goselect v0.1.2 // indirect diff --git a/go.sum b/go.sum index e6a7f3c..9165012 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0 github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= +github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= diff --git a/web/server.go b/web/server.go new file mode 100644 index 0000000..3b5b1e7 --- /dev/null +++ b/web/server.go @@ -0,0 +1,224 @@ +package web + +import ( + "embed" + "fmt" + "html/template" + "io/fs" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "track-gopher/derby" +) + +//go:embed templates static +var content embed.FS + +// Server represents the web server for the derby clock +type Server struct { + router *chi.Mux + clock *derby.DerbyClock + templates *template.Template + raceEvents chan derby.Event + clients map[chan string]bool + port int +} + +// NewServer creates a new web server +func NewServer(clock *derby.DerbyClock, port int) (*Server, error) { + // Parse templates + templates, err := template.ParseFS(content, "templates/*.html", "templates/**/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse templates: %w", err) + } + + // Create server + s := &Server{ + router: chi.NewRouter(), + clock: clock, + templates: templates, + raceEvents: make(chan derby.Event, 10), + clients: make(map[chan string]bool), + port: port, + } + + // Set up routes + s.routes() + + // Start event forwarder + go s.forwardEvents() + + return s, nil +} + +// routes sets up the HTTP routes +func (s *Server) routes() { + // Middleware + s.router.Use(middleware.Logger) + s.router.Use(middleware.Recoverer) + s.router.Use(middleware.Timeout(10 * time.Second)) + + // Static files + staticFS, _ := fs.Sub(content, "static") + s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + + // Pages + s.router.Get("/", s.handleIndex()) + + // API endpoints + s.router.Post("/api/reset", s.handleReset()) + s.router.Post("/api/force-end", s.handleForceEnd()) + s.router.Get("/api/status", s.handleStatus()) + s.router.Get("/api/events", s.handleEvents()) +} + +// Start starts the web server +func (s *Server) Start() error { + fmt.Printf("Starting web server on port %d...\n", s.port) + return http.ListenAndServe(fmt.Sprintf(":%d", s.port), s.router) +} + +// forwardEvents forwards derby clock events to the web clients +func (s *Server) forwardEvents() { + for event := range s.clock.Events() { + // Store the event for new clients + s.raceEvents <- event + + // Create the SSE message based on the event type + var message string + switch event.Type { + case derby.EventRaceStart: + message = "event: race-start\ndata: {\"status\": \"running\"}\n\n" + + case derby.EventLaneFinish: + result := event.Result + message = fmt.Sprintf("event: lane-finish\ndata: {\"lane\": %d, \"place\": %d, \"time\": %.4f}\n\n", + result.Lane, result.FinishPlace, result.Time) + + case derby.EventRaceComplete: + message = "event: race-complete\ndata: {\"status\": \"finished\"}\n\n" + } + + // Send to all connected clients + for clientChan := range s.clients { + select { + case clientChan <- message: + // Message sent successfully + default: + // Client is not receiving, remove it + delete(s.clients, clientChan) + close(clientChan) + } + } + } +} + +// handleIndex handles the index page +func (s *Server) handleIndex() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.templates.ExecuteTemplate(w, "index.html", nil) + } +} + +// handleReset handles the reset API endpoint +func (s *Server) handleReset() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := s.clock.Reset(); err != nil { + http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status": "reset"}`)) + } +} + +// handleForceEnd handles the force end API endpoint +func (s *Server) handleForceEnd() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := s.clock.ForceEnd(); err != nil { + http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status": "forced"}`)) + } +} + +// handleStatus handles the status API endpoint +func (s *Server) handleStatus() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + status := s.clock.Status() + + var statusStr string + switch status { + case derby.StatusIdle: + statusStr = "idle" + case derby.StatusRunning: + statusStr = "running" + case derby.StatusFinished: + statusStr = "finished" + } + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr))) + } +} + +// handleEvents handles the SSE events endpoint +func (s *Server) handleEvents() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Set headers for SSE + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Create a channel for this client + clientChan := make(chan string, 10) + s.clients[clientChan] = true + + // Clean up when the client disconnects + defer func() { + delete(s.clients, clientChan) + close(clientChan) + }() + + // Send initial status + status := s.clock.Status() + var statusStr string + switch status { + case derby.StatusIdle: + statusStr = "idle" + case derby.StatusRunning: + statusStr = "running" + case derby.StatusFinished: + statusStr = "finished" + } + + fmt.Fprintf(w, "event: status\ndata: {\"status\": \"%s\"}\n\n", statusStr) + w.(http.Flusher).Flush() + + // Keep the connection open + for { + select { + case message, ok := <-clientChan: + if !ok { + return + } + + // Send the message to the client + fmt.Fprint(w, message) + w.(http.Flusher).Flush() + + case <-r.Context().Done(): + // Client disconnected + return + } + } + } +} diff --git a/web/static/css/main.css b/web/static/css/main.css new file mode 100644 index 0000000..fb541b2 --- /dev/null +++ b/web/static/css/main.css @@ -0,0 +1,19 @@ +/* Additional custom styles can go here */ +.place-1st { + color: gold; + font-weight: bold; +} + +.place-2nd { + color: silver; + font-weight: bold; +} + +.place-3rd { + color: #cd7f32; /* bronze */ + font-weight: bold; +} + +.place-4th { + color: #666; +} \ No newline at end of file diff --git a/web/templates/index.html b/web/templates/index.html new file mode 100644 index 0000000..a04f46c --- /dev/null +++ b/web/templates/index.html @@ -0,0 +1,191 @@ + + +
+ + +