feat!: expand to internal project, add app layer, develop service layer

This commit is contained in:
2025-06-22 18:16:16 -05:00
parent 0ea312c1ad
commit 0a51119e4c
9 changed files with 452 additions and 187 deletions

View File

@@ -36,7 +36,7 @@ tasks:
dev: dev:
desc: Development workflow - build, test, and run desc: Development workflow - build, test, and run
deps: [deps, fmt, vet, test, windows:build] deps: [deps, fmt, vet, test, build]
cmds: cmds:
- echo "Development workflow complete" - echo "Development workflow complete"
- echo "Run 'task windows:run' to start the application" - echo "Run 'task windows:run' to start the application"

25
cmd/main.go Normal file
View File

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

2
go.mod
View File

@@ -1,4 +1,4 @@
module hass-tray module ha-tray
go 1.21 go 1.21

110
internal/app.go Normal file
View File

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

148
internal/app/app.go Normal file
View File

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

View File

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

149
internal/service/windows.go Normal file
View File

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

184
main.go
View File

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

View File

@@ -8,7 +8,7 @@ vars:
tasks: tasks:
build: build:
cmds: cmds:
- go build -o ./dist/{{.EXE_NAME}} . - go build -o ./dist/{{.EXE_NAME}} ./cmd/main.go
package: package:
deps: [build] deps: [build]