226 lines
6.0 KiB
Go
226 lines
6.0 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 media
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"strconv"
|
|
|
|
"github.com/eclipse/paho.golang/paho"
|
|
|
|
mqtthass "github.com/joshuar/go-hass-anything/v12/pkg/hass"
|
|
mqttapi "github.com/joshuar/go-hass-anything/v12/pkg/mqtt"
|
|
|
|
"github.com/joshuar/go-hass-agent/internal/logging"
|
|
"github.com/joshuar/go-hass-agent/internal/preferences"
|
|
"github.com/joshuar/go-hass-agent/pkg/linux/pulseaudiox"
|
|
)
|
|
|
|
const (
|
|
muteIcon = "mdi:volume-mute"
|
|
volIcon = "mdi:knob"
|
|
|
|
minVolpc = 0
|
|
maxVolpc = 100
|
|
volStepPc = 1
|
|
)
|
|
|
|
// audioControl is a struct containing the data for providing audio state
|
|
// tracking and control.
|
|
type audioControl struct {
|
|
pulseAudio *pulseaudiox.PulseAudioClient
|
|
msgCh chan *mqttapi.Msg
|
|
muteEntity *mqtthass.SwitchEntity
|
|
volEntity *mqtthass.NumberEntity[int]
|
|
logger *slog.Logger
|
|
}
|
|
|
|
// entity is a convienience interface to treat all entities the same.
|
|
type entity interface {
|
|
MarshalState(args ...any) (*mqttapi.Msg, error)
|
|
}
|
|
|
|
//nolint:lll
|
|
func VolumeControl(ctx context.Context, msgCh chan *mqttapi.Msg, device *mqtthass.Device) (*mqtthass.NumberEntity[int], *mqtthass.SwitchEntity) {
|
|
control, err := newAudioControl(ctx, msgCh)
|
|
if err != nil {
|
|
logging.FromContext(ctx).Warn("Could not configure Pulseaudio. Volume control will not be available.", slog.Any("error", err))
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
control.volEntity = mqtthass.NewNumberEntity[int]().
|
|
WithMin(minVolpc).
|
|
WithMax(maxVolpc).
|
|
WithStep(volStepPc).
|
|
WithMode(mqtthass.NumberSlider).
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Volume"),
|
|
mqtthass.ID(device.Name+"_volume"),
|
|
mqtthass.DeviceInfo(device),
|
|
mqtthass.Icon(volIcon),
|
|
).
|
|
WithState(
|
|
mqtthass.StateCallback(control.volStateCallback),
|
|
mqtthass.ValueTemplate("{{ value_json.value }}"),
|
|
).
|
|
WithCommand(
|
|
mqtthass.CommandCallback(control.volCommandCallback),
|
|
)
|
|
|
|
control.muteEntity = mqtthass.NewSwitchEntity().
|
|
OptimisticMode().
|
|
WithDetails(
|
|
mqtthass.App(preferences.AppName),
|
|
mqtthass.Name("Mute"),
|
|
mqtthass.ID(device.Name+"_mute"),
|
|
mqtthass.DeviceInfo(device),
|
|
mqtthass.Icon(muteIcon),
|
|
).
|
|
WithState(
|
|
mqtthass.StateCallback(control.muteStateCallback),
|
|
mqtthass.ValueTemplate("{{ value }}"),
|
|
).
|
|
WithCommand(
|
|
mqtthass.CommandCallback(control.muteCommandCallback),
|
|
)
|
|
|
|
update := func() { // Pulseaudio changed state. Get the new state.
|
|
// Publish and update mute state if it changed.
|
|
if err := publishAudioState(msgCh, control.muteEntity); err != nil {
|
|
control.logger.Error("Failed to publish mute state to MQTT.", slog.Any("error", err))
|
|
}
|
|
|
|
// Publish and update volume if it changed.
|
|
if err := publishAudioState(msgCh, control.volEntity); err != nil {
|
|
control.logger.Error("Failed to publish mute state to MQTT.", slog.Any("error", err))
|
|
}
|
|
}
|
|
|
|
// Process Pulseaudio state updates as they are received.
|
|
go func() {
|
|
control.logger.Debug("Monitoring for events.")
|
|
update()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
control.logger.Debug("Closing connection.")
|
|
|
|
return
|
|
case <-control.pulseAudio.EventCh:
|
|
update()
|
|
}
|
|
}
|
|
}()
|
|
|
|
return control.volEntity, control.muteEntity
|
|
}
|
|
|
|
// newAudioControl will establish a connection to PulseAudio and return a
|
|
// audioControl object for tracking and controlling audio state.
|
|
func newAudioControl(ctx context.Context, msgCh chan *mqttapi.Msg) (*audioControl, error) {
|
|
client, err := pulseaudiox.NewPulseClient(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to connect to Pulseaudio: %w", err)
|
|
}
|
|
|
|
audioDev := &audioControl{
|
|
pulseAudio: client,
|
|
msgCh: msgCh,
|
|
logger: logging.FromContext(ctx).WithGroup("volume_controller"),
|
|
}
|
|
|
|
return audioDev, nil
|
|
}
|
|
|
|
// volStateCallback is executed when the volume is read on MQTT.
|
|
func (d *audioControl) volStateCallback(_ ...any) (json.RawMessage, error) {
|
|
vol, err := d.pulseAudio.GetVolume()
|
|
if err != nil {
|
|
return json.RawMessage(`{ "value": 0 }`), err
|
|
}
|
|
|
|
d.logger.Debug("Publishing volume change.", slog.Int("volume", int(vol)))
|
|
|
|
return json.RawMessage(`{ "value": ` + strconv.FormatFloat(vol, 'f', 0, 64) + ` }`), nil
|
|
}
|
|
|
|
// volCommandCallback is called when the volume is changed on MQTT.
|
|
func (d *audioControl) volCommandCallback(p *paho.Publish) {
|
|
if newValue, err := strconv.Atoi(string(p.Payload)); err != nil {
|
|
d.logger.Debug("Could not parse new volume level.", slog.Any("error", err))
|
|
} else {
|
|
d.logger.Debug("Received volume change from Home Assistant.", slog.Int("volume", newValue))
|
|
|
|
if err := d.pulseAudio.SetVolume(float64(newValue)); err != nil {
|
|
d.logger.Error("Could not set volume level.", slog.Any("error", err))
|
|
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
if err := publishAudioState(d.msgCh, d.volEntity); err != nil {
|
|
d.logger.Error("Failed to publish mute state to MQTT.", slog.Any("error", err))
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// muteStateCallback is executed when the mute state is read on MQTT.
|
|
func (d *audioControl) muteStateCallback(_ ...any) (json.RawMessage, error) {
|
|
muteVal, err := d.pulseAudio.GetMute()
|
|
if err != nil {
|
|
return json.RawMessage(`OFF`), err
|
|
}
|
|
|
|
switch muteVal {
|
|
case true:
|
|
return json.RawMessage(`ON`), nil
|
|
default:
|
|
return json.RawMessage(`OFF`), nil
|
|
}
|
|
}
|
|
|
|
// muteCommandCallback is executed when the mute state is changed on MQTT.
|
|
func (d *audioControl) muteCommandCallback(p *paho.Publish) {
|
|
var err error
|
|
|
|
state := string(p.Payload)
|
|
switch state {
|
|
case "ON":
|
|
err = d.pulseAudio.SetMute(true)
|
|
case "OFF":
|
|
err = d.pulseAudio.SetMute(false)
|
|
}
|
|
|
|
if err != nil {
|
|
d.logger.Error("Could not set mute state.", slog.Any("error", err))
|
|
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
if err := publishAudioState(d.msgCh, d.muteEntity); err != nil {
|
|
d.logger.Error("Failed to publish mute state to MQTT.", slog.Any("error", err))
|
|
}
|
|
}()
|
|
}
|
|
|
|
func publishAudioState(msgCh chan *mqttapi.Msg, entity entity) error {
|
|
msg, err := entity.MarshalState()
|
|
if err != nil {
|
|
return fmt.Errorf("could not marshal entity state: %w", err)
|
|
}
|
|
msgCh <- msg
|
|
|
|
return nil
|
|
}
|