diff --git a/Taskfile.yml b/Taskfile.yml index 3ca3515..29e4e65 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -36,7 +36,7 @@ tasks: dev: desc: Development workflow - build, test, and run - deps: [deps, fmt, vet, test, windows:build] + deps: [deps, fmt, vet, test, build] cmds: - echo "Development workflow complete" - echo "Run 'task windows:run' to start the application" \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..2116255 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,25 @@ +package main + +import ( + "fmt" + "log" + "os" + + "ha-tray/internal" +) + +func main() { + // Create new application instance + app := internal.NewApp() + + // Setup the application (logging, panic handling, service initialization) + if err := app.Setup(); err != nil { + log.Fatalf("Failed to setup application: %v", err) + } + + // Run the application + if err := app.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Application error: %v\n", err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 9952a93..1e57aa5 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module hass-tray +module ha-tray go 1.21 diff --git a/internal/app.go b/internal/app.go new file mode 100644 index 0000000..db314a5 --- /dev/null +++ b/internal/app.go @@ -0,0 +1,110 @@ +package internal + +import ( + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + + "ha-tray/internal/app" + "ha-tray/internal/service" +) + +// App represents the main application +type App struct { + appLayer *app.App + service service.Service + logger *slog.Logger + logFile *os.File +} + +// NewApp creates a new application instance +func NewApp() *App { + return &App{} +} + +// Setup initializes the application with logging and panic handling +func (a *App) Setup() error { + // Setup panic recovery + defer a.recoverPanic() + + // Setup logging + if err := a.setupLogging(); err != nil { + return fmt.Errorf("failed to setup logging: %v", err) + } + + // Setup app layer + a.appLayer = app.NewApp(a.logger) + + // Setup service + if err := a.setupService(); err != nil { + return fmt.Errorf("failed to setup service: %v", err) + } + + return nil +} + +// Run starts the application +func (a *App) Run() error { + defer a.cleanup() + + a.logger.Info("Starting HATray application") + + // Run the service + return a.service.Run() +} + +// setupLogging initializes structured logging +func (a *App) setupLogging() error { + // Get the directory where the executable is located + exePath, err := os.Executable() + if err != nil { + return fmt.Errorf("failed to get executable path: %v", err) + } + exeDir := filepath.Dir(exePath) + + // Open log file in the same directory as the executable + logFile, err := os.OpenFile(filepath.Join(exeDir, "current.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %v", err) + } + + a.logFile = logFile + + // Create multi-writer to log to both file and stdout + multiWriter := io.MultiWriter(logFile, os.Stdout) + + // Create JSON handler for structured logging + handler := slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + a.logger = slog.New(handler) + + return nil +} + +// setupService initializes the platform-specific service +func (a *App) setupService() error { + // Platform-specific service initialization using build flags + a.service = service.NewService(a.logger, a.appLayer) + return nil +} + +// recoverPanic handles panic recovery and logs the error +func (a *App) recoverPanic() { + if r := recover(); r != nil { + if a.logger != nil { + a.logger.Error("Panic recovered", "panic", r) + } else { + fmt.Printf("Panic recovered: %v\n", r) + } + } +} + +// cleanup performs cleanup operations +func (a *App) cleanup() { + if a.logFile != nil { + a.logFile.Close() + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..abbdc81 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,148 @@ +package app + +import ( + "log/slog" + "sync" +) + +// App represents the main application layer that is generic and cross-platform +type App struct { + logger *slog.Logger + mu sync.RWMutex + state AppState +} + +// AppState represents the current state of the application +type AppState string + +const ( + StateRunning AppState = "running" + StatePaused AppState = "paused" + StateStopped AppState = "stopped" +) + +// NewApp creates a new application instance +func NewApp(logger *slog.Logger) *App { + return &App{ + logger: logger, + state: StateRunning, + } +} + +// Pause disconnects from the server and ceases any background tasks +func (a *App) Pause() error { + a.mu.Lock() + defer a.mu.Unlock() + + a.logger.Info("Pausing application", + "action", "pause", + "previous_state", a.state, + "new_state", StatePaused) + + // TODO: Implement actual pause logic + // - Disconnect from Home Assistant WebSocket + // - Stop background tasks + // - Pause sensor monitoring + + a.state = StatePaused + + a.logger.Info("Application paused successfully", + "action", "pause", + "state", a.state) + + return nil +} + +// Resume connects to the server and initiates background tasks +func (a *App) Resume() error { + a.mu.Lock() + defer a.mu.Unlock() + + a.logger.Info("Resuming application", + "action", "resume", + "previous_state", a.state, + "new_state", StateRunning) + + // TODO: Implement actual resume logic + // - Connect to Home Assistant WebSocket + // - Start background tasks + // - Resume sensor monitoring + + a.state = StateRunning + + a.logger.Info("Application resumed successfully", + "action", "resume", + "state", a.state) + + return nil +} + +// Reload pauses the application, re-reads configuration files, then resumes +func (a *App) Reload() error { + a.logger.Info("Starting application reload", + "action", "reload", + "current_state", a.state) + + // Pause if not already paused + if a.state != StatePaused { + if err := a.Pause(); err != nil { + a.logger.Error("Failed to pause during reload", + "action", "reload", + "error", err) + return err + } + } + + // TODO: Implement configuration reload logic + // - Re-read TOML configuration files + // - Validate configuration + // - Update internal state with new configuration + + a.logger.Info("Configuration reloaded successfully", + "action", "reload") + + // Resume the application + if err := a.Resume(); err != nil { + a.logger.Error("Failed to resume after reload", + "action", "reload", + "error", err) + return err + } + + a.logger.Info("Application reload completed successfully", + "action", "reload", + "final_state", a.state) + + return nil +} + +// GetState returns the current state of the application +func (a *App) GetState() AppState { + a.mu.RLock() + defer a.mu.RUnlock() + return a.state +} + +// Stop stops the application completely +func (a *App) Stop() error { + a.mu.Lock() + defer a.mu.Unlock() + + a.logger.Info("Stopping application", + "action", "stop", + "previous_state", a.state, + "new_state", StateStopped) + + // TODO: Implement actual stop logic + // - Disconnect from all services + // - Clean up resources + // - Stop all background tasks + + a.state = StateStopped + + a.logger.Info("Application stopped successfully", + "action", "stop", + "state", a.state) + + return nil +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..a3bf822 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,17 @@ +package service + +import ( + "ha-tray/internal/app" + "log/slog" +) + +// This is an intentionally very-simple interface as the main program entrypoint needs to know very little about the service layer. +// The service layer is completely responsible for the lifecycle of the application, implemented per-platform. +type Service interface { + Run() error +} + +// NewService creates a new service instance for the current platform +func NewService(logger *slog.Logger, appLayer *app.App) Service { + return newService(logger, appLayer) +} diff --git a/internal/service/windows.go b/internal/service/windows.go new file mode 100644 index 0000000..7b96470 --- /dev/null +++ b/internal/service/windows.go @@ -0,0 +1,149 @@ +//go:build windows + +package service + +import ( + "fmt" + "log/slog" + "time" + + "ha-tray/internal/app" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/debug" + "golang.org/x/sys/windows/svc/eventlog" +) + +const serviceName = "HATray" + +// WindowsService implements the Service interface for Windows +type WindowsService struct { + logger *slog.Logger + elog debug.Log + isDebug bool + app *app.App +} + +// newService creates a new Windows service instance +func newService(logger *slog.Logger, appLayer *app.App) Service { + return &WindowsService{ + logger: logger, + app: appLayer, + } +} + +// Run implements the Service interface for Windows +func (w *WindowsService) Run() error { + // Determine if we're running as a Windows service + isService, err := svc.IsWindowsService() + if err != nil { + return fmt.Errorf("failed to determine if running as Windows service: %v", err) + } + + w.isDebug = !isService + + if isService { + return w.runAsService() + } + + // Interactive mode + return w.runInteractive() +} + +// runAsService runs the application as a Windows service +func (w *WindowsService) runAsService() error { + var err error + if w.isDebug { + w.elog = debug.New(serviceName) + } else { + w.elog, err = eventlog.Open(serviceName) + if err != nil { + return fmt.Errorf("failed to open event log: %v", err) + } + } + defer w.elog.Close() + + w.elog.Info(1, fmt.Sprintf("starting %s service", serviceName)) + + run := svc.Run + if w.isDebug { + run = debug.Run + } + + err = run(serviceName, &windowsServiceHandler{ + service: w, + }) + + if err != nil { + w.elog.Error(1, fmt.Sprintf("%s service failed: %v", serviceName, err)) + return err + } + + w.elog.Info(1, fmt.Sprintf("%s service stopped", serviceName)) + return nil +} + +// runInteractive runs the application in interactive mode +func (w *WindowsService) runInteractive() error { + w.logger.Info("Application starting in interactive mode") + + // Simple interactive loop + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for range ticker.C { + w.logger.Debug("Application heartbeat") + } + + return nil +} + +// windowsServiceHandler implements the Windows service handler interface +type windowsServiceHandler struct { + service *WindowsService +} + +func (h *windowsServiceHandler) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) { + const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue + changes <- svc.Status{State: svc.StartPending} + + h.service.logger.Info("Service starting") + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + + // Main service loop + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case c := <-r: + switch c.Cmd { + case svc.Interrogate: + changes <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + h.service.logger.Info("Service stopping") + changes <- svc.Status{State: svc.StopPending} + if err := h.service.app.Stop(); err != nil { + h.service.logger.Error("Failed to stop app layer", "error", err) + } + return + case svc.Pause: + h.service.logger.Info("Service pausing") + changes <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted} + if err := h.service.app.Pause(); err != nil { + h.service.logger.Error("Failed to pause app layer", "error", err) + } + case svc.Continue: + h.service.logger.Info("Service continuing") + changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} + if err := h.service.app.Resume(); err != nil { + h.service.logger.Error("Failed to resume app layer", "error", err) + } + default: + h.service.elog.Error(uint32(1), fmt.Sprintf("unexpected control request #%d", c)) + } + case <-ticker.C: + h.service.logger.Debug("Service heartbeat") + } + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 8787062..0000000 --- a/main.go +++ /dev/null @@ -1,184 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "time" - - "golang.org/x/sys/windows/svc" - "golang.org/x/sys/windows/svc/debug" - "golang.org/x/sys/windows/svc/eventlog" -) - -var elog debug.Log - -type myservice struct{} - -func (m *myservice) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) { - const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown | svc.AcceptPauseAndContinue - changes <- svc.Status{State: svc.StartPending} - - // Get the directory where the executable is located - exePath, err := os.Executable() - if err != nil { - elog.Error(1, fmt.Sprintf("Failed to get executable path: %v", err)) - return - } - exeDir := filepath.Dir(exePath) - - // Open log file in the same directory as the executable - logFile, err := os.OpenFile(filepath.Join(exeDir, "current.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - elog.Error(1, fmt.Sprintf("Failed to open log file: %v", err)) - return - } - defer logFile.Close() - - // Create JSON logger - logger := log.New(logFile, "", 0) - - // Log startup - startupLog := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "level": "debug", - "message": "Service starting", - "service": "hass-tray", - } - startupJSON, _ := json.Marshal(startupLog) - logger.Println(string(startupJSON)) - - changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} - - // Main service loop - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for { - select { - case c := <-r: - switch c.Cmd { - case svc.Interrogate: - changes <- c.CurrentStatus - case svc.Stop, svc.Shutdown: - // Log shutdown - shutdownLog := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "level": "debug", - "message": "Service stopping", - "service": "hass-tray", - } - shutdownJSON, _ := json.Marshal(shutdownLog) - logger.Println(string(shutdownJSON)) - - changes <- svc.Status{State: svc.StopPending} - return - case svc.Pause: - changes <- svc.Status{State: svc.Paused, Accepts: cmdsAccepted} - case svc.Continue: - changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted} - default: - elog.Error(uint32(1), fmt.Sprintf("unexpected control request #%d", c)) - } - case <-ticker.C: - // Log heartbeat - heartbeatLog := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "level": "debug", - "message": "Service heartbeat", - "service": "hass-tray", - } - heartbeatJSON, _ := json.Marshal(heartbeatLog) - logger.Println(string(heartbeatJSON)) - } - } -} - -func runService(name string, isDebug bool) { - var err error - if isDebug { - elog = debug.New(name) - } else { - elog, err = eventlog.Open(name) - if err != nil { - return - } - } - defer elog.Close() - - elog.Info(1, fmt.Sprintf("starting %s service", name)) - run := svc.Run - if isDebug { - run = debug.Run - } - err = run(name, &myservice{}) - if err != nil { - elog.Error(1, fmt.Sprintf("%s service failed: %v", name, err)) - return - } - elog.Info(1, fmt.Sprintf("%s service stopped", name)) -} - -func main() { - isDebug, err := svc.IsAnInteractiveSession() - if err != nil { - log.Fatalf("failed to determine if we are running in an interactive session: %v", err) - } - - if !isDebug { - runService("hass-tray", false) - return - } - - // Interactive mode - just run the service logic directly - fmt.Println("Running in interactive mode...") - - // Get the current directory for log file - currentDir, err := os.Getwd() - if err != nil { - log.Fatalf("Failed to get current directory: %v", err) - } - - // Open log file - logFile, err := os.OpenFile(filepath.Join(currentDir, "current.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Fatalf("Failed to open log file: %v", err) - } - defer logFile.Close() - - // Create JSON logger - logger := log.New(logFile, "", 0) - - // Log startup - startupLog := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "level": "debug", - "message": "Application starting in interactive mode", - "service": "hass-tray", - } - startupJSON, _ := json.Marshal(startupLog) - logger.Println(string(startupJSON)) - - fmt.Println("Press Ctrl+C to stop...") - - // Simple interactive loop - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Log heartbeat - heartbeatLog := map[string]interface{}{ - "timestamp": time.Now().Format(time.RFC3339), - "level": "debug", - "message": "Application heartbeat", - "service": "hass-tray", - } - heartbeatJSON, _ := json.Marshal(heartbeatLog) - logger.Println(string(heartbeatJSON)) - } - } -} diff --git a/tasks/Taskfile_windows.yml b/tasks/Taskfile_windows.yml index 06a05ee..9d5495c 100644 --- a/tasks/Taskfile_windows.yml +++ b/tasks/Taskfile_windows.yml @@ -8,7 +8,7 @@ vars: tasks: build: cmds: - - go build -o ./dist/{{.EXE_NAME}} . + - go build -o ./dist/{{.EXE_NAME}} ./cmd/main.go package: deps: [build]