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

235 lines
5.6 KiB
Go

// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com>
//
// This software is released under the MIT License.
// https://opensource.org/licenses/MIT
//revive:disable:unused-receiver
package scripts
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/iancoleman/strcase"
"github.com/pelletier/go-toml/v2"
"github.com/rs/zerolog/log"
"gopkg.in/yaml.v3"
"github.com/joshuar/go-hass-agent/internal/hass/sensor"
"github.com/joshuar/go-hass-agent/internal/hass/sensor/types"
)
type Script struct {
Output chan sensor.Details
path string
schedule string
}
//nolint:exhaustruct
func (s *Script) execute() (*scriptOutput, error) {
cmd := exec.Command(s.path)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("could not execute script: %w", err)
}
output := &scriptOutput{}
err = output.Unmarshal(out)
if err != nil {
return nil, fmt.Errorf("could not parse script output: %w", err)
}
return output, nil
}
// Run is the function that is called when a script is run by the scheduler on
// its specified schedule. It is implemented to satisfy the cron package
// interface, so the script can be treated as a cron job. Run will execute the
// script, collect the output and send it through a channel as a sensor object.
func (s *Script) Run() {
output, err := s.execute()
if err != nil {
log.Warn().Err(err).Str("script", s.path).
Msg("Could not run script.")
return
}
for _, o := range output.Sensors {
s.Output <- o
}
}
// Schedule retrieves the cron schedule that the script should be run on.
func (s *Script) Schedule() string {
return s.schedule
}
// Path returns the path to the script on disk.
func (s *Script) Path() string {
return s.path
}
// NewScript returns a new script object that can scheduled with the job
// scheduler by the agent.
func NewScript(path string) *Script {
script := &Script{
path: path,
schedule: "",
Output: make(chan sensor.Details),
}
out, err := script.execute()
if err != nil {
log.Warn().Err(err).Str("script", path).
Msg("Cannot run script")
return nil
}
script.schedule = out.Schedule
return script
}
// scriptOutput represents the output from a script. The output must be
// formatted as either valid JSON or YAML. This output is used to define a
// sensor in Home Assistant.
//
//nolint:tagalign
type scriptOutput struct {
Schedule string `json:"schedule" yaml:"schedule"`
Sensors []*scriptSensor `json:"sensors" yaml:"sensors"`
}
// Unmarshal will attempt to take the raw output from a script execution and
// format it as either JSON or YAML. If successful, this format can then be used
// as a sensor.
func (o *scriptOutput) Unmarshal(scriptOutput []byte) error {
jsonErr := json.Unmarshal(scriptOutput, &o)
if jsonErr == nil {
return nil
}
yamlErr := yaml.Unmarshal(scriptOutput, &o)
if yamlErr == nil {
return nil
}
tomlErr := toml.Unmarshal(scriptOutput, &o)
if tomlErr == nil {
return nil
}
return errors.Join(jsonErr, yamlErr, tomlErr)
}
//nolint:tagalign
type scriptSensor struct {
SensorState any `json:"sensor_state" yaml:"sensor_state" toml:"sensor_state"`
SensorAttributes any `json:"sensor_attributes,omitempty" yaml:"sensor_attributes,omitempty" toml:"sensor_attributes,omitempty"`
SensorName string `json:"sensor_name" yaml:"sensor_name" toml:"sensor_name"`
SensorIcon string `json:"sensor_icon" yaml:"sensor_icon" toml:"sensor_icon"`
SensorDeviceClass string `json:"sensor_device_class,omitempty" yaml:"sensor_device_class,omitempty" toml:"sensor_device_class,omitempty"`
SensorStateClass string `json:"sensor_state_class,omitempty" yaml:"sensor_state_class,omitempty" toml:"sensor_state_class,omitempty"`
SensorStateType string `json:"sensor_type,omitempty" yaml:"sensor_type,omitempty" toml:"sensor_type,omitempty"`
SensorUnits string `json:"sensor_units,omitempty" yaml:"sensor_units,omitempty" toml:"sensor_units,omitempty"`
}
func (s *scriptSensor) Name() string {
return s.SensorName
}
func (s *scriptSensor) ID() string {
return strcase.ToSnake(s.SensorName)
}
func (s *scriptSensor) Icon() string {
return s.SensorIcon
}
func (s *scriptSensor) SensorType() types.SensorClass {
switch s.SensorStateType {
case "binary":
return types.BinarySensor
default:
return types.Sensor
}
}
func (s *scriptSensor) DeviceClass() types.DeviceClass {
for d := types.DeviceClassApparentPower; d <= types.DeviceClassWindSpeed; d++ {
if s.SensorDeviceClass == d.String() {
return d
}
}
return 0
}
func (s *scriptSensor) StateClass() types.StateClass {
switch s.SensorStateClass {
case "measurement":
return types.StateClassMeasurement
case "total":
return types.StateClassTotal
case "total_increasing":
return types.StateClassTotalIncreasing
default:
return 0
}
}
func (s *scriptSensor) State() any {
return s.SensorState
}
func (s *scriptSensor) Units() string {
return s.SensorUnits
}
func (s *scriptSensor) Category() string {
return ""
}
func (s *scriptSensor) Attributes() any {
return s.SensorAttributes
}
// FindScripts locates scripts and returns a slice of scripts that the agent can
// run.
func FindScripts(path string) ([]*Script, error) {
var scripts []*Script
files, err := filepath.Glob(path + "/*")
if err != nil {
return nil, fmt.Errorf("could not search for scripts: %w", err)
}
for _, s := range files {
if isExecutable(s) {
script := NewScript(s)
if script != nil {
scripts = append(scripts, script)
}
}
}
return scripts, nil
}
func isExecutable(filename string) bool {
fi, err := os.Stat(filename)
if err != nil {
return false
}
return fi.Mode().Perm()&0o111 != 0
}