Added basic web interface

main
DustyP 9 months ago
parent b0a45e4faf
commit e128569a29

@ -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
}
}

@ -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

@ -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=

@ -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
}
}
}
}

@ -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;
}

@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pinewood Derby Race Manager</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
.lane {
transition: all 0.3s ease-in-out;
}
.lane.finished {
background-color: #d1fae5;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<div class="container mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-center text-blue-800">Pinewood Derby Race Manager</h1>
</header>
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold">Race Control</h2>
<div id="race-status" class="px-4 py-2 rounded-full text-white bg-gray-500">
Connecting...
</div>
</div>
<div class="flex space-x-4 justify-center">
<button
hx-post="/api/reset"
hx-swap="none"
class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
Reset Race
</button>
<button
hx-post="/api/force-end"
hx-swap="none"
class="bg-yellow-600 hover:bg-yellow-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
Force End
</button>
</div>
</div>
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
<h2 class="text-xl font-semibold mb-4">Race Results</h2>
<div id="lanes-container" class="space-y-4">
<div id="lane-1" class="lane p-4 border rounded-lg flex justify-between items-center">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center text-white font-bold">1</div>
<span class="ml-4 font-medium">Lane 1</span>
</div>
<div class="text-right">
<span class="place hidden"></span>
<span class="time text-lg font-mono">--.--.---</span>
</div>
</div>
<div id="lane-2" class="lane p-4 border rounded-lg flex justify-between items-center">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">2</div>
<span class="ml-4 font-medium">Lane 2</span>
</div>
<div class="text-right">
<span class="place hidden"></span>
<span class="time text-lg font-mono">--.--.---</span>
</div>
</div>
<div id="lane-3" class="lane p-4 border rounded-lg flex justify-between items-center">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full bg-green-500 flex items-center justify-center text-white font-bold">3</div>
<span class="ml-4 font-medium">Lane 3</span>
</div>
<div class="text-right">
<span class="place hidden"></span>
<span class="time text-lg font-mono">--.--.---</span>
</div>
</div>
<div id="lane-4" class="lane p-4 border rounded-lg flex justify-between items-center">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full bg-yellow-500 flex items-center justify-center text-white font-bold">4</div>
<span class="ml-4 font-medium">Lane 4</span>
</div>
<div class="text-right">
<span class="place hidden"></span>
<span class="time text-lg font-mono">--.--.---</span>
</div>
</div>
</div>
</div>
</div>
<!-- SSE Events Source -->
<div hx-ext="sse" sse-connect="/api/events">
<div sse-swap="race-start" hx-swap-oob="true">
<script>
document.getElementById('race-status').textContent = 'Race Running';
document.getElementById('race-status').className = 'px-4 py-2 rounded-full text-white bg-green-600';
// Reset all lanes
document.querySelectorAll('.lane').forEach(lane => {
lane.classList.remove('finished');
lane.querySelector('.time').textContent = '--.--.---';
lane.querySelector('.place').classList.add('hidden');
});
</script>
</div>
<div sse-swap="lane-finish" hx-swap-oob="true">
<script>
const laneFinishData = JSON.parse(event.data);
const lane = document.getElementById(`lane-${laneFinishData.lane}`);
if (lane) {
lane.classList.add('finished');
lane.querySelector('.time').textContent = laneFinishData.time.toFixed(4);
const placeEl = lane.querySelector('.place');
placeEl.textContent = `${getOrdinal(laneFinishData.place)} Place`;
placeEl.classList.remove('hidden');
}
function getOrdinal(n) {
const s = ["th", "st", "nd", "rd"];
const v = n % 100;
return n + (s[(v-20)%10] || s[v] || s[0]);
}
</script>
</div>
<div sse-swap="race-complete" hx-swap-oob="true">
<script>
document.getElementById('race-status').textContent = 'Race Complete';
document.getElementById('race-status').className = 'px-4 py-2 rounded-full text-white bg-purple-600';
</script>
</div>
<div sse-swap="status" hx-swap-oob="true">
<script>
const statusData = JSON.parse(event.data);
let statusText = 'Unknown';
let statusClass = 'bg-gray-500';
if (statusData.status === 'idle') {
statusText = 'Ready';
statusClass = 'bg-blue-600';
// Reset all lanes
document.querySelectorAll('.lane').forEach(lane => {
lane.classList.remove('finished');
lane.querySelector('.time').textContent = '--.--.---';
lane.querySelector('.place').classList.add('hidden');
});
} else if (statusData.status === 'running') {
statusText = 'Race Running';
statusClass = 'bg-green-600';
} else if (statusData.status === 'finished') {
statusText = 'Race Complete';
statusClass = 'bg-purple-600';
}
document.getElementById('race-status').textContent = statusText;
document.getElementById('race-status').className = `px-4 py-2 rounded-full text-white ${statusClass}`;
</script>
</div>
</div>
<script>
// Handle button states based on race status
document.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.path === '/api/reset' || event.detail.path === '/api/force-end') {
// Refresh the page status after reset or force end
fetch('/api/status')
.then(response => response.json())
.then(data => {
// Update UI based on status
console.log('Race status updated:', data.status);
});
}
});
</script>
</body>
</html>
Loading…
Cancel
Save