211 lines
5.2 KiB
Go
211 lines
5.2 KiB
Go
// 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
|
|
}
|