Skip to content

Commit

Permalink
feat(agent): ✨ support "switch" type custom MQTT commands
Browse files Browse the repository at this point in the history
Add the ability to define custom MQTT commands that are a switch toggle in Home Assistant.
  • Loading branch information
joshuar committed Jun 19, 2024
1 parent 2d03c84 commit 26f3272
Show file tree
Hide file tree
Showing 2 changed files with 112 additions and 10 deletions.
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,23 +689,40 @@ Supported control types and expected input/output:

- [Button](https://www.home-assistant.io/integrations/button.mqtt/).
- Output is discarded. Return value is used to indicate success/failure.
- [Switch](https://www.home-assistant.io/integrations/switch.mqtt/).
- Return value is used to indicate success/failure.
- When the switch is toggled in Home Assistant, Go Hass Agent will run the
configured command with an “ON” or “OFF” appended to the end of its
command-line.
- When the configured command is run, it should output the current state as
“ON” or “OFF”. Any additional output is ignored and any output that doesn't
match these strings will indicate an error to the agent.

> [!NOTE]
> Commands run as the user running the agent. Commands do not invoke the system
> shell and does not support expansion/glob patterns or handle other expansions,
> pipelines, or redirections typically done by shells.
>
> States are not kept in sync. This is most important for all controls besides
> buttons. For example, if you configure a switch, any changes to the state you
> make outside of Home Assistant will not be reflected in Home Assistant
> automatically.
Each command needs the following definition in the file:

```toml
[[control]] # where "control" is one of the control types above.
name = "my command name" # required. the pretty name of the command that will the control label in Home Assistant.
exec = "/path/to/command" # required. the path to the command to execute.
icon = "mdi:something" # optional. the material design icon to use to represent the control in Home Assistant.
# "control" should be replaced with one of the control types above.
[[control]]
# name is required. The pretty name of the command that will be the label in Home Assistant.
name = "my command name"
# exec is required. The path to the command to execute. Arguments can be given as required, and should be quoted if they contain spaces.
exec = '/path/to/command arg1 "arg with space"'
# icon is optional. The material design icon to use to represent the control in Home Assistant. See https://pictogrammers.com/library/mdi/ for icons you can use.
icon = "mdi:something"
```

The following shows an example that configures two buttons
in Home Assistant:
The following shows an example that configures some buttons and a switch in Home
Assistant:

```toml
[[button]]
Expand All @@ -716,6 +733,10 @@ in Home Assistant:
[[button]]
name = "My Command"
exec = "command"

[[switch]]
name = "Toggle a Thing"
exec = "command arg1 arg2"
```

#### Security Implications
Expand Down
89 changes: 85 additions & 4 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
package commands

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
Expand All @@ -29,7 +31,10 @@ import (
)

// ErrNoCommands indicates there were no commands to configure.
var ErrNoCommands = errors.New("no commands")
var (
ErrNoCommands = errors.New("no commands")
ErrUnknownSwitchState = errors.New("could not parse current state of switch")
)

// Command represents a Command to run by a button or switch.
type Command struct {
Expand All @@ -46,8 +51,8 @@ type Command struct {
//nolint:tagalign
//revive:disable:struct-tag
type CommandList struct {
Buttons []Command `toml:"button,omitempty" koanf:"button"`
// Switches []Command `koanf:"switches"`
Buttons []Command `toml:"button,omitempty" koanf:"button"`
Switches []Command `toml:"switch,omitempty" koanf:"switch"`
}

// Controller represents an object with one or more buttons and switches
Expand Down Expand Up @@ -157,7 +162,8 @@ func NewCommandsController(ctx context.Context, commandsFile string, device *mqt
// switches a user has defined.
func newController(_ context.Context, device *mqtthass.Device, commands *CommandList) *Controller {
controller := &Controller{
buttons: generateButtons(commands.Buttons, device),
buttons: generateButtons(commands.Buttons, device),
switches: generateSwitches(commands.Switches, device),
}

return controller
Expand Down Expand Up @@ -211,3 +217,78 @@ func buttonCmd(command string) error {

return nil
}

// generateButtons will create MQTT entities for buttons defined by the
// controller.
func generateSwitches(switchCmds []Command, device *mqtthass.Device) []*mqtthass.SwitchEntity {
var id, icon, name string

entities := make([]*mqtthass.SwitchEntity, 0, len(switchCmds))

for _, cmd := range switchCmds {
cmdCallBack := func(p *paho.Publish) {
state := string(p.Payload)

err := switchCmd(cmd.Exec, state)
if err != nil {
log.Warn().Err(err).Str("command", cmd.Name).Msg("Switch change failed.")
}
}
stateCallBack := func(_ ...any) (json.RawMessage, error) {
return switchState(cmd.Exec)
}
name = cmd.Name
id = strcase.ToSnake(device.Name + "_" + cmd.Name)

if cmd.Icon != "" {
icon = cmd.Icon
} else {
icon = "mdi:toggle-switch"
}

entities = append(entities,
mqtthass.AsSwitch(
mqtthass.NewEntity(preferences.AppName, name, id).
WithOriginInfo(preferences.MQTTOrigin()).
WithDeviceInfo(device).
WithIcon(icon).
WithStateCallback(stateCallBack).
WithCommandCallback(cmdCallBack),
true))
}

return entities
}

// buttonCmd runs the executable associated with a button. Buttons are not
// expected to accept any input, or produce any consumable output, so only the
// return value is checked.
func switchCmd(command, state string) error {
cmdElems := strings.Split(command, " ")
cmdElems = append(cmdElems, state)

_, err := exec.Command(cmdElems[0], cmdElems[1:]...).Output()
if err != nil {
return fmt.Errorf("could not execute button command: %w", err)
}

return nil
}

func switchState(command string) (json.RawMessage, error) {
cmdElems := strings.Split(command, " ")

output, err := exec.Command(cmdElems[0], cmdElems[1:]...).Output()
if err != nil {
return nil, fmt.Errorf("could get switch state: %w", err)
}

switch {
case bytes.Contains(output, []byte(`ON`)):
return json.RawMessage(`ON`), nil
case bytes.Contains(output, []byte(`OFF`)):
return json.RawMessage(`OFF`), nil
}

return nil, ErrUnknownSwitchState
}

0 comments on commit 26f3272

Please sign in to comment.