Mac2mqtt — control volume on macOS via MQTT

I have iMac on my table. Sometimes I watch video on it.

When I use kodi, I can control the computer from Home Assistant. But when I watch something in the browser I have no way to control the volume remotely.

I’ve tried to find some project that solves such a task, but I could not find any.

So, I have written a simple program, to solve my task. mac2mqtt (can you guess the software that inspired the name of this project?)

mac2mqtt sends to MQTT server info about volume, mute status and if the program is connected to MQTT. You can send volume to special topic and it will set to volume on the mac. You can also mute/unmute and put commuter to sleep.

1

The sorce code, documentation and Home Assistant sample integration is on GitHub — GitHub - bessarabov/mac2mqtt: View and control some aspects of macOS computer via MQTT

What do you think? Do you need to control your mac from Home Assistant? This project is proudly called mac2mqtt, but it does just a tiny thing of controlling mac. Do you need something else? If you like the project, can you please star it GitHub.

1 Like

I’ve sold it now (waiting for the next model to be launched), but I SSH’ed to my iMac to put it to sleep every night when I turned off the lights in the living room.

pmset displaysleepnow

Don’t know if you’re willing to add that?

1 Like

Thank you! I like this idea.

I’ve just released 1.1.0 that does that — Release 1.1.0 · bessarabov/mac2mqtt · GitHub

1 Like

Thats awesome!! :hugs:
A further usefull command, if this would be possible, could be a shutdown

1 Like

Short question: do i have to install Go for that to run? because i put the files in the named folder, run chmod and tried ./mac2mqtt but it only shows that this is a folder.

edit: got it working, do have to install go and get dependencies manually - perhaps it would be usefull to add that in the readme

edit 2: I got a problem with my m1 Mac - It says

strconv.Atoi: parsing “missing value”: invalid syntax
exit status 1

edit 3: editing the parts with strconv so no fatal error occurs help to get “sleep” and "alive working, but “display sleep” further don’t work=(

package main

import (
	"fmt"
	"gopkg.in/yaml.v2"
	"io/ioutil"
	"log"
	"os"
	"os/exec"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	mqtt "github.com/eclipse/paho.mqtt.golang"
)

var hostname string

type config struct {
	Ip       string `yaml:"mqtt_ip"`
	Port     string `yaml:"mqtt_port"`
	User     string `yaml:"mqtt_user"`
	Password string `yaml:"mqtt_password"`
}

func (c *config) getConfig() *config {

	configContent, err := ioutil.ReadFile("mac2mqtt.yaml")
	if err != nil {
		log.Fatal(err)
	}

	err = yaml.Unmarshal(configContent, c)
	if err != nil {
		log.Fatal(err)
	}

	if c.Ip == "" {
		log.Fatal("Must specify mqtt_ip in mac2mqtt.yaml")
	}

	if c.Port == "" {
		log.Fatal("Must specify mqtt_port in mac2mqtt.yaml")
	}

	if c.User == "" {
		log.Fatal("Must specify mqtt_user in mac2mqtt.yaml")
	}

	if c.Password == "" {
		log.Fatal("Must specify mqtt_password in mac2mqtt.yaml")
	}

	return c
}

func getHostname() string {

	hostname, err := os.Hostname()

	if err != nil {
		log.Fatal(err)
	}

	// "name.local" => "name"
	firstPart := strings.Split(hostname, ".")[0]

	// remove all symbols, but [a-zA-Z0-9_-]
	reg, err := regexp.Compile("[^a-zA-Z0-9_-]+")
	if err != nil {
		log.Fatal(err)
	}
	firstPart = reg.ReplaceAllString(firstPart, "")

	return firstPart
}

func getCommandOutput(name string, arg ...string) string {
	cmd := exec.Command(name, arg...)

	stdout, err := cmd.Output()
	if err != nil {
		log.Fatal(err)
	}

	stdoutStr := string(stdout)
	stdoutStr = strings.TrimSuffix(stdoutStr, "\n")

	return stdoutStr
}

func getMuteStatus() bool {
	output := getCommandOutput("/usr/bin/osascript", "-e", "output muted of (get volume settings)")

	b, err := strconv.ParseBool(output)
	if err != nil {
		return b
	}

	return b
}

func getCurrentVolume() int {
	output := getCommandOutput("/usr/bin/osascript", "-e", "output volume of (get volume settings)")

	i, err := strconv.Atoi(output)
	if err != nil {
		return i
	}

	return i
}

func runCommand(name string, arg ...string) {
	cmd := exec.Command(name, arg...)

	_, err := cmd.Output()
	if err != nil {
		log.Fatal(err)
	}
}

// from 0 to 100
func setVolume(i int) {
	runCommand("/usr/bin/osascript", "-e", "set volume output volume "+strconv.Itoa(i))
}

// true - turn mute on
// false - turn mute off
func setMute(b bool) {
	runCommand("/usr/bin/osascript", "-e", "set volume output muted "+strconv.FormatBool(b))
}

func commandSleep() {
	runCommand("pmset", "sleepnow")
}

func commandDisplaySleep() {
	runCommand("pmset", "displaysleepnow")
}

var messagePubHandler mqtt.MessageHandler = func(client mqtt.Client, msg mqtt.Message) {
	log.Printf("Received message: %s from topic: %s\n", msg.Payload(), msg.Topic())
}

var connectHandler mqtt.OnConnectHandler = func(client mqtt.Client) {
	log.Println("Connected to MQTT")

	token := client.Publish(getTopicPrefix()+"/status/alive", 0, true, "true")
	token.Wait()

	log.Println("Sending 'true' to topic: " + getTopicPrefix() + "/status/alive")

	listen(client, getTopicPrefix()+"/command/#")
}

var connectLostHandler mqtt.ConnectionLostHandler = func(client mqtt.Client, err error) {
	log.Printf("Disconnected from MQTT: %v", err)
}

func getMQTTClient(ip, port, user, password string) mqtt.Client {

	opts := mqtt.NewClientOptions()
	opts.AddBroker(fmt.Sprintf("tcp://%s:%s", ip, port))
	opts.SetUsername(user)
	opts.SetPassword(password)
	opts.OnConnect = connectHandler
	opts.OnConnectionLost = connectLostHandler

	opts.SetWill(getTopicPrefix()+"/status/alive", "false", 0, true)

	client := mqtt.NewClient(opts)
	if token := client.Connect(); token.Wait() && token.Error() != nil {
		panic(token.Error())
	}

	return client
}

func getTopicPrefix() string {
	return "mac2mqtt/" + hostname
}

func listen(client mqtt.Client, topic string) {

	token := client.Subscribe(topic, 0, func(client mqtt.Client, msg mqtt.Message) {

		if msg.Topic() == getTopicPrefix()+"/command/volume" {

			i, err := strconv.Atoi(string(msg.Payload()))
			if err == nil && i >= 0 && i <= 100 {

				setVolume(i)

				updateVolume(client)
				updateMute(client)

			} else {
				log.Println("Incorrect value")
			}

		}

		if msg.Topic() == getTopicPrefix()+"/command/mute" {

			b, err := strconv.ParseBool(string(msg.Payload()))
			if err == nil {
				setMute(b)

				updateVolume(client)
				updateMute(client)

			} else {
				log.Println("Incorrect value")
			}

		}

		if msg.Topic() == getTopicPrefix()+"/command/sleep" {

			if string(msg.Payload()) == "sleep" {
				commandSleep()
			}

		}

		if msg.Topic() == getTopicPrefix()+"/command/displaysleep" {

			if string(msg.Payload()) == "displaysleep" {
				commandDisplaySleep()
			}

		}

	})

	token.Wait()
	if token.Error() != nil {
		log.Printf("Token error: %s\n", token.Error())
	}
}

func updateVolume(client mqtt.Client) {
	token := client.Publish(getTopicPrefix()+"/status/volume", 0, false, strconv.Itoa(getCurrentVolume()))
	token.Wait()
}

func updateMute(client mqtt.Client) {
	token := client.Publish(getTopicPrefix()+"/status/mute", 0, false, strconv.FormatBool(getMuteStatus()))
	token.Wait()
}

func main() {

	log.Println("Started")

	var c config
	c.getConfig()

	var wg sync.WaitGroup

	hostname = getHostname()
	mqttClient := getMQTTClient(c.Ip, c.Port, c.User, c.Password)
	volumeTicker := time.NewTicker(2 * time.Second)

	wg.Add(1)
	go func() {
		for {
			select {
			case _ = <-volumeTicker.C:
				updateVolume(mqttClient)
				updateMute(mqttClient)
			}
		}
	}()

	wg.Wait()

}

edit 4: found out, that the error is related to external monitor connected which doesn’t support audio control - so if audio goes through hdmi the app crashes, so before posted dirty fix which prevents error exit solves this. Further “Displaysleepnow” doesn’t work at my Mac - it is working on my MacBook, so I guess its also related to my Mac mini

You are right! Thank you. I’ve released 1.2.0 that can shutdown mac. At first I though that it can be done only if you run this program by root user, but I have found the way to do it with ordinary user as well (but the shutdown by the ordinary user is only possible when there are no other users logged to the system).

1 Like

Short question: do i have to install Go for that to run?

Well, I’m building binary for mac2mqtt and I’m putting them in releases on GitHub. But I’m doing it for intel mac, not for the new m1 macs. So, for the m1 macs the answer is “yes” you need to build it the binary.

I have created a ticket that I should prepare builds not only for intel, but for m1 also — In releases build binaries for m1 mac · Issue #2 · bessarabov/mac2mqtt · GitHub

strconv.Atoi: parsing “missing value”: invalid syntax

What output do you get when you run /usr/bin/osascript -e 'output volume of (get volume settings)' in cosole?

I wonder, is it possible to know if audio is currently being played?

It would be good for room presence as you could be sat still while watching a video or listening to music.

In this scenario you wouldn’t be generating any motion and the HA Mac app still reports idle when watching video. I assume it’s based purely on input received.

Now im wondering if it would be possible to pause/play any media (like when you press F8 key)

Then I could automatically stop any videos or music when a meeting is due :smiley: …that would be heavenly.

perhaps it will also work over rosetta 2 =)

it depends: if running audio through internal speaker it say, I assume correctly, 93 but if I run audio through my external monitor over hdmi It says “missing value” - I think this is also the point where the app crashes.

and big thanks for 1.2 release=) im really excited how you managed the shutdown=)

edit 1: still wanting to put out that line 94 - 114 is causing panic exit with external monitor if not changed to :

func getMuteStatus() bool {
	output := getCommandOutput("/usr/bin/osascript", "-e", "output muted of (get volume settings)")

	b, err := strconv.ParseBool(output)
	if err != nil {
		return b
	}

	return b
}

func getCurrentVolume() int {
	output := getCommandOutput("/usr/bin/osascript", "-e", "output volume of (get volume settings)")

	i, err := strconv.Atoi(output)
	if err != nil {
		return i
	}

	return i
}

I would like to add such sensor to the project, but I don’t know how to do it. I google-ed a bit, but didn’t find a way to get that info.

It is possible to use some external device to measure noise level in the room. For example, there is such a sensor in Netatmo weather station.

It is already possible to mute the computer. I don’t know to emulate pressing the media keys on keyboard. It is not implemented in mac2mqtt, but it is technically is possible to interact with software directly (e.g. send “stop” to iTunes), but this is not universal solution.

I’m sorry for additional questions, but I want to understand the situation. So, you are running the same command twice /usr/bin/osascript -e 'output volume of (get volume settings)'
first time it output number, and the second time it output missing value, it that correct? How do you change the output to your external monitor?

Can you please show me the output of the command /usr/bin/osascript -e 'get volume settings' when the output is your external monitor?

no worries, im sorry for all my silly request!!!

Yes that’s correct, if I change audio output via system settings to the external monitor, or via the ?command center? after running the exact same command in terminal it says “missing value” - I guess that this lies in the fact that I also can’t change volume with hdmi on Mac - earlier there was a tool called “monitorcontrol” but this isn’t working anymore with m1 Macs.

with internal speaker it says:

output volume:93, input volume:missing value, alert volume:100, output muted:false

with audio through external hdmi monitor it says:

output volume:missing value, input volume:missing value, alert volume:100, output muted:missing value

but this isn’t anything new to me at all, but its annoying that the app crashes - so I guess it lies in the aforementioned lines.
my dirty fix works till now - I don’t know if it spawns the system with unnecessary information but with external monitor no crash, and with internal Audio volume settings are still working

greetings

1 Like

Thank you. I think that now I have all the needed details from your side. I will implement the fix to make sure the soft is working in such situation, but I can’t say you the exact date when I fix it. I need to think about the way how to do it properly and it will take some time.

1 Like

after a while using your component, i would add to the possible requests that it would be awesome to have an fallback second ip addresse for the mqttserver. so if it fails to connect to the first it would try to connect to the second one

1 Like

Wow! Very interesting idea! Thank you!

I have never hear of anybody who have server mqtt servers. Can you please explain how you have them connected to Home Assistant? ( I was sure that Home Assistant can make connection only to one MQTT server)

Its not having different server at once but in different networks i have two different Mqttserver IPs, so i meant that if the app cant connect to the one ip adresse it uses a different, for instance taking the laptop from one place to an other but in both there are hassio instances.

1 Like

Oh. Now I get it. Yes, I completely agree this should be possible. I’ve created a ticket on GitHub about this — Several mqtt servers in config · Issue #3 · bessarabov/mac2mqtt · GitHub

1 Like

Hi @bessarabov this is a great project and just what I was about to start building. So glad I found it. There is one thing I want to be able to trigger on my mac that Mac2mqtt doesn’t currently handle and it made me wonder about a general way to add aribitrary functionality to mac2mqtt. What if you added several “custom actions” with topics like:

  • "mac2mqtt/bessarabov-osx/applescript/custom1”
  • "mac2mqtt/bessarabov-osx/applescript/custom2”
  • etc.

and then in mac2mqtt.yaml the user could configure paths to arbitrary applescripts to be executed:

custom1: /path/to/applescript1
custom2: /path/to/applescript2

What do you think?

1 Like