// Copyright (c) 2024 Joshua Rich <joshua.rich@gmail.com> // // This software is released under the MIT License. // https://opensource.org/licenses/MIT package scripts import ( "encoding/json" "errors" "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 } func (s *script) execute() (*scriptOutput, error) { cmd := exec.Command(s.path) o, err := cmd.Output() if err != nil { return nil, err } output := &scriptOutput{} err = output.Unmarshal(o) if err != nil { return nil, 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(p string) *script { s := &script{ path: p, Output: make(chan sensor.Details), } o, err := s.execute() if err != nil { log.Warn().Err(err).Str("script", p). Msg("Cannot run script") return nil } s.schedule = o.Schedule return s } // 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. 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(b []byte) error { jsonErr := json.Unmarshal(b, &o) if jsonErr == nil { return nil } yamlErr := yaml.Unmarshal(b, &o) if yamlErr == nil { return nil } tomlErr := toml.Unmarshal(b, &o) if tomlErr == nil { return nil } return errors.Join(jsonErr, yamlErr, tomlErr) } 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, 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 }