joshuar-go-hass-agent/internal/scripts/worker.go

223 lines
4.6 KiB
Go

// Copyright 2024 Joshua Rich <joshua.rich@gmail.com>.
// SPDX-License-Identifier: MIT
//revive:disable:unused-receiver
package scripts
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"github.com/adrg/xdg"
"github.com/robfig/cron/v3"
"github.com/joshuar/go-hass-agent/internal/hass/sensor"
"github.com/joshuar/go-hass-agent/internal/logging"
"github.com/joshuar/go-hass-agent/internal/preferences"
)
var (
ErrUnknownScript = errors.New("unknown or nonexistent script")
ErrAlreadyStarted = errors.New("script already started")
ErrAlreadyStopped = errors.New("script already stopped")
ErrSchedulingFailed = errors.New("failed to schedule script")
)
type job struct {
logAttrs slog.Attr
Script
ID cron.EntryID
}
type Worker struct {
sensorCh chan sensor.Entity
scheduler *cron.Cron
logger *slog.Logger
jobs []job
}
// TODO: implement ability to disable.
func (c *Worker) Disabled() bool {
return false
}
func (c *Worker) ID() string {
return "scripts"
}
func (c *Worker) States(_ context.Context) []sensor.Entity {
var sensors []sensor.Entity
for _, worker := range c.activeJobs() {
found := slices.IndexFunc(c.jobs, func(j job) bool { return j.path == worker })
jobSensors, err := c.jobs[found].Execute()
if err != nil {
c.logger.Warn("Could not retrieve script sensors",
slog.String("script", c.jobs[found].path),
slog.Any("error", err),
)
} else {
sensors = append(sensors, jobSensors...)
}
}
return sensors
}
func (c *Worker) activeJobs() []string {
var activeScripts []string
for _, job := range c.jobs {
if job.ID != 0 {
activeScripts = append(activeScripts, job.path)
}
}
return activeScripts
}
func (c *Worker) inactiveJobs() []string {
var inactiveScripts []string
for _, job := range c.jobs {
if job.ID == 0 {
inactiveScripts = append(inactiveScripts, job.path)
}
}
return inactiveScripts
}
func (c *Worker) Start(_ context.Context) (<-chan sensor.Entity, error) {
c.sensorCh = make(chan sensor.Entity)
for idx := range c.inactiveJobs() {
// Schedule the script.
id, err := c.scheduler.AddFunc(c.jobs[idx].Schedule(), func() {
sensors, err := c.jobs[idx].Execute()
if err != nil {
c.logger.Warn("Could not execute script.",
c.jobs[idx].logAttrs,
slog.Any("error", err))
return
}
for _, o := range sensors {
c.sensorCh <- o
}
})
if err != nil {
c.logger.Warn("Could not schedule script.",
c.jobs[idx].logAttrs,
slog.Any("error", err))
continue
}
// Update the job id.
c.jobs[idx].ID = id
c.logger.Debug("Scheduled script.",
c.jobs[idx].logAttrs)
}
// Start the scheduler.
c.scheduler.Start()
// Return the new sensor channel for the script.
return c.sensorCh, nil
}
func (c *Worker) Stop() error {
for idx := range c.activeJobs() {
// If the script is already stopped, return an error.
if c.jobs[idx].ID == 0 {
c.logger.Warn("Script already stopped.",
c.jobs[idx].logAttrs)
continue
}
c.scheduler.Remove(c.jobs[idx].ID)
c.jobs[idx].ID = 0
}
// Stop the scheduler.
c.scheduler.Stop()
close(c.sensorCh)
return nil
}
// NewScriptController creates a new sensor worker for scripts.
func NewScriptsWorker(ctx context.Context) (*Worker, error) {
appID := preferences.AppIDFromContext(ctx)
scriptPath := filepath.Join(xdg.ConfigHome, appID, "scripts")
worker := &Worker{
scheduler: cron.New(),
logger: logging.FromContext(ctx).With(slog.String("controller", "scripts")),
}
scripts, err := findScripts(scriptPath)
if err != nil {
return nil, fmt.Errorf("could not find scripts: %w", err)
}
worker.jobs = make([]job, 0, len(scripts))
for _, s := range scripts {
logAttrs := slog.Group("job", slog.String("script", s.path), slog.String("schedule", s.schedule))
worker.jobs = append(worker.jobs, job{Script: *s, logAttrs: logAttrs})
}
return worker, nil
}
// findScripts locates scripts and returns a slice of scripts that the agent can
// run.
func findScripts(path string) ([]*Script, error) {
var sensorScripts []*Script
var errs error
files, err := filepath.Glob(path + "/*")
if err != nil {
return nil, fmt.Errorf("could not search for scripts: %w", err)
}
for _, scriptFile := range files {
if isExecutable(scriptFile) {
script, err := NewScript(scriptFile)
if err != nil {
errs = errors.Join(errs, err)
continue
}
sensorScripts = append(sensorScripts, script)
}
}
return sensorScripts, nil
}
// isExecutable is helper to determine if a (script) file is executable.
func isExecutable(filename string) bool {
fi, err := os.Stat(filename)
if err != nil {
return false
}
return fi.Mode().Perm()&0o111 != 0
}