zaphyra's git: airpodsctl

Control and monitor your AirPods

commit 568d36744a20b45e898ece4fd478fa2076b3654b
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Tue, 12 Aug 2025 19:48:25 +0200

initial commit
26 files changed, 1251 insertions(+), 0 deletions(-)
A
.gitignore
|
2
++
A
flake.lock
|
27
+++++++++++++++++++++++++++
A
flake.nix
|
45
+++++++++++++++++++++++++++++++++++++++++++++
A
src/bluetoothAACPCommands.go
|
53
+++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/cmdDumpDeviceState.go
|
26
++++++++++++++++++++++++++
A
src/cmdListDevices.go
|
22
++++++++++++++++++++++
A
src/cmdMonitor.go
|
127
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/cmdToggleAncMode.go
|
51
+++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/cmdToggleFeature.go
|
38
++++++++++++++++++++++++++++++++++++++
A
src/go.mod
|
5
+++++
A
src/go.sum
|
2
++
A
src/helpStrings.go
|
29
+++++++++++++++++++++++++++++
A
src/helpers/cli.go
|
9
+++++++++
A
src/helpers/math.go
|
18
++++++++++++++++++
A
src/main.go
|
142
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/state.go
|
160
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types/ANCMode.go
|
80
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types/Any.go
|
40
++++++++++++++++++++++++++++++++++++++++
A
src/types/BatteryTTLEstimate.go
|
34
++++++++++++++++++++++++++++++++++
A
src/types/Bool.go
|
37
+++++++++++++++++++++++++++++++++++++
A
src/types/Device.go
|
36
++++++++++++++++++++++++++++++++++++
A
src/types/Devices.go
|
24
++++++++++++++++++++++++
A
src/types/OutputFormat.go
|
36
++++++++++++++++++++++++++++++++++++
A
src/types/StringSlice.go
|
27
+++++++++++++++++++++++++++
A
src/types/formatDeviceCli.go
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
src/types/formatDeviceWaybar.go
|
95
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+src/airpodsctl
+result
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1754689972,
+        "narHash": "sha256-eogqv6FqZXHgqrbZzHnq43GalnRbLTkbBbFtEfm1RSc=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "fc756aa6f5d3e2e5666efcf865d190701fef150a",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-25.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,45 @@
+{
+
+  description = "airpodsctl";
+
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
+
+  outputs = inputs: let
+    forAllSystems = function:
+      inputs.nixpkgs.lib.genAttrs [
+        "x86_64-linux"
+        "aarch64-linux"
+        "aarch64-darwin"
+      ] (system: function (import inputs.nixpkgs {
+        system   = system;
+        overlays = [ inputs.self.overlays.default ];
+      }));
+
+  in {
+
+    packages = forAllSystems (pkgs: {
+      default    = pkgs.airpodsctl;
+      airpodsctl = pkgs.airpodsctl;
+    });
+
+    devShells = forAllSystems (pkgs: {
+      default = pkgs.mkShell {
+        buildInputs = with pkgs; [
+          go
+        ];
+        shellHook = "";
+      };
+    });
+
+    overlays.default = final: prev: {
+      airpodsctl = final.buildGoModule (finalAttrs: {
+        name = "airpodsctl";
+        src  = "${inputs.self}/src";
+        vendorHash = "sha256-WUTGAYigUjuZLHO1YpVhFSWpvULDZfGMfOXZQqVYAfs=";
+
+        meta.mainProgram = "airpodsctl";
+      });
+    };
+  };
+
+}
diff --git a/src/bluetoothAACPCommands.go b/src/bluetoothAACPCommands.go
@@ -0,0 +1,53 @@
+package main
+
+import (
+	"git.zaphyra.eu/airpodsctl/types"
+)
+
+var AACPHandshakePacket = []byte{0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+var AACPRequestNotificationPacket = []byte{0x04, 0x00, 0x04, 0x00, 0x0F, 0x00, 0xFF, 0xFF, 0xFE, 0xFF}
+var AACPControlCommandPacket = []byte{0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+
+var AACPControlCommandListeningMode = map[types.ANCMode]byte{
+	types.ANCModeOff:          0x01,
+	types.ANCModeOn:           0x02,
+	types.ANCModeTransparency: 0x03,
+	types.ANCModeAdaptive:     0x04,
+}
+
+var AACPControlCommandBool = map[bool]byte{
+	true:  0x01,
+	false: 0x02,
+}
+
+var AACPControlCommand = map[string]byte{
+	"MicMode":                   0x01, // ??
+	"ButtonSendMode":            0x05, // ??
+	"VoiceTriggerSiri":          0x12, // ??
+	"SingleClickMode":           0x14, // ??
+	"DoubleClickMode":           0x15, // ??
+	"ClickHoldMode":             0x16, // two values, First byte = right bud, Second byte = left bud: 0x01 = Noise control 0x05 = Siri
+	"DoubleClickInterval":       0x17, // single value: 0x00 = Default, 0x01 = Slower, 0x02 = Slowest
+	"ClickHoldInterval":         0x18, // single value: 0x00 = Default, 0x01 = Slower, 0x02 = Slowest
+	"ListeningModeConfigs":      0x1A, // single value: bitwise OR of the selected modes. Off mode = 0x01, ANC=0x02, Transparency = 0x04, Adaptive = 0x08
+	"OneBudANCMode":             0x1B, // single value: 0x01 = enabled, 0x02 = disabled
+	"CrownRotationDirection":    0x1C, // single value: 0x01 = reversed, 0x02 = default
+	"ListeningMode":             0x0D, // single value: 0x01 = Off, 0x02 = ANC, 0x03 = Transparency, 0x04 = Adaptive
+	"AutoAnswerMode":            0x1E, // ??
+	"ChimeVolume":               0x1F, // single value: 0 -> 100
+	"VolumeSwipeInterval":       0x23, // single value: 0x00 = Default, 0x01 = Longer, 0x02 = Longest
+	"CallManagementConfig":      0x24, // ??
+	"VolumeSwipeConfig":         0x25, // single value: 0x01 = enabled, 0x02 = disabled
+	"AdaptiveVolumeConfig":      0x26, // single value: 0x01 = enabled, 0x02 = disabled
+	"SoftwareMuteConfig":        0x27, // ??
+	"ConversationDetectConfig":  0x28, // single value: 0x01 = enabled, 0x02 = disabled
+	"SSL":                       0x29, // ??
+	"HearingAidEnrolledEnabled": 0x2C, // two values, First byte - enrolled, Second byte = enabled): 0x01 = enabled, 0x02 = disabled
+	"AutoANCStrength":           0x2E, // single value: 0 -> 100
+	"HPSGainSwipe":              0x2F, // ??
+	"HRMState":                  0x30, // ??
+	"InCaseToneConfig":          0x31, // single value: 0x01 = enabled, 0x02 = disabled
+	"SiriMultitoneConfig":       0x32, // ??
+	"HearingAssistConfig":       0x33, // single value: 0x01 = enabled, 0x02 = disabled
+	"AllowANCOffConfig":         0x34, // single value: 0x01 = enabled, 0x02 = disabled
+}
diff --git a/src/cmdDumpDeviceState.go b/src/cmdDumpDeviceState.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/types"
+)
+
+func (state State) dumpDeviceStateCommand() {
+	device, err := state.Devices.Get(state.SelectedDevice)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[dumpDeviceStateCommand] Error: Device '%s' is not connected!\n", state.SelectedDevice)
+		os.Exit(1)
+	}
+
+	switch state.OutputFormat {
+	case types.FormatWaybar:
+		fmt.Fprint(os.Stdout, device.FormatWaybarString())
+	case types.FormatCLI:
+		output, _ := device.FormatCLIString()
+		fmt.Fprint(os.Stdout, output)
+	}
+
+	os.Exit(0)
+}
diff --git a/src/cmdListDevices.go b/src/cmdListDevices.go
@@ -0,0 +1,22 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/helpers"
+)
+
+func (state State) listDevicesCommand() {
+	if len(state.Devices) < 1 {
+		fmt.Fprintln(os.Stderr, "No devices connected!")
+		os.Exit(1)
+	}
+
+	fmt.Fprintln(os.Stdout, helpers.BoldText("[MAC-Address]\t\tConnected\tName"))
+	for _, device := range state.Devices {
+		fmt.Fprintf(os.Stdout, "[%s]\t%s\t\t%s\n", device.Address, device.Connected, device.Name)
+	}
+
+	os.Exit(0)
+}
diff --git a/src/cmdMonitor.go b/src/cmdMonitor.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/types"
+
+	"github.com/godbus/dbus/v5"
+)
+
+func (state State) monitorCommand() {
+	var lines int
+
+	device, err := state.Devices.Get(state.SelectedDevice)
+	if err == nil {
+		switch state.OutputFormat {
+		case types.FormatWaybar:
+			fmt.Fprint(os.Stdout, device.FormatWaybarString())
+		case types.FormatCLI:
+			var output string
+			output, lines = device.FormatCLIString()
+			fmt.Fprintf(os.Stdout, "\033[?25l%s\033[%dA", output, lines)
+		}
+	}
+
+	state.RegisterSignals()
+
+	for signal := range state.Signals {
+		if signal == nil {
+			return
+		}
+
+		switch signal.Name {
+		case "org.kairpods.manager.DeviceConnected":
+			macAddr := signal.Body[0].(string)
+			device, _ := state.Devices.Get(macAddr)
+			batteryStateBuds := types.StringSlice{}
+
+			if device.Battery.LeftBud.Valid || device.Battery.RightBud.Valid || device.BatteryTTLEstimate.Valid {
+				batteryStateBuds.Add("\n<small> 󰁹</small>")
+
+				if device.Battery.LeftBud.Valid {
+					var chargeSymbol string
+					if device.Battery.LeftBud.Value.Charging {
+						chargeSymbol = " <span size=\"smaller\" rise=\"1pt\"></span>"
+					}
+
+					batteryStateBuds.Add(fmt.Sprintf("<small>L: %d%%%#s</small>", device.Battery.LeftBud.Value.Level, chargeSymbol))
+				}
+
+				if device.Battery.RightBud.Valid {
+					var chargeSymbol string
+					if device.Battery.RightBud.Value.Charging {
+						chargeSymbol = " <span size=\"smaller\" rise=\"1pt\"></span>"
+					}
+
+					batteryStateBuds.Add(fmt.Sprintf("<small>R: %d%%%#s</small>", device.Battery.RightBud.Value.Level, chargeSymbol))
+				}
+
+				if device.BatteryTTLEstimate.Valid {
+					batteryStateBuds.Add(fmt.Sprintf("<small>(󱧦 ~%s remaining)</small>", device.BatteryTTLEstimate))
+				}
+			}
+
+			state.SendNotify(fmt.Sprintf("Device '%s' connected!%s", device.Name, batteryStateBuds.ConcatString(" ")), 4, "")
+
+		case "org.kairpods.manager.DeviceDisconnected":
+			macAddr := signal.Body[0].(string)
+			device, _ := state.Devices.Get(macAddr)
+
+			state.SendNotify(fmt.Sprintf("Device '%s' disconnected!", device.Name), 2, "")
+
+		// currently unusable, because there is no event triggered for anc-mode 'adaptive'
+		// case "org.kairpods.manager.NoiseControlChanged":
+		//	macAddr := signal.Body[0].(string)
+		//	ancMode := signal.Body[1].(string)
+
+		//	fmt.Println(ancMode)
+
+		//	state.SendNotify(fmt.Sprintf("Device '%s' switched to ANC-Mode: %s", state.Devices.Get(macAddr).Name, parseANCMode(ancMode).FormatString()), 2)
+
+		case "org.freedesktop.DBus.Properties.PropertiesChanged":
+			changedProperties := signal.Body[1].(map[string]dbus.Variant)
+
+			if value, hasKey := changedProperties["Devices"]; hasKey {
+				var devicesStr string
+				var devices types.Devices
+
+				value.Store(&devicesStr)
+
+				if err := json.Unmarshal([]byte(devicesStr), &devices); err != nil {
+					state.PrintDebug(fmt.Sprintf("[monitorCommand] Faild to unmarshal devices-JSON: %s\n", err))
+					return
+				}
+
+				oldDeviceState, oldDeviceErr := state.Devices.Get(state.SelectedDevice)
+				newDeviceState, newDeviceErr := devices.Get(state.SelectedDevice)
+
+				state.Devices = devices
+
+				if newDeviceErr == nil {
+					switch state.OutputFormat {
+					case types.FormatWaybar:
+						fmt.Fprint(os.Stdout, newDeviceState.FormatWaybarString())
+					case types.FormatCLI:
+						var output string
+						output, lines = newDeviceState.FormatCLIString()
+						fmt.Fprintf(os.Stdout, "\033[?25l\033[0J%s\033[%dA", output, lines)
+					}
+
+					if oldDeviceErr == nil {
+						if oldDeviceState.ANCMode != newDeviceState.ANCMode {
+							state.SendNotify(fmt.Sprintf("Switched to ANC-Mode: %s", newDeviceState.ANCMode.Value), 2, newDeviceState.Name)
+						}
+					}
+				}
+			}
+
+		default:
+			state.PrintDebug(fmt.Sprintf("[monitorCommand] Received unknown signal '%s': %+v\n", signal.Name, signal.Body))
+		}
+	}
+
+	os.Exit(0)
+}
diff --git a/src/cmdToggleAncMode.go b/src/cmdToggleAncMode.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/types"
+)
+
+func (state State) toggleAncModeCommand(ancMode types.ANCMode) {
+	device, err := state.Devices.Get(state.SelectedDevice)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[setAncModeCommand] Error: Device '%s' is not connected!\n", state.SelectedDevice)
+		os.Exit(1)
+	}
+
+	if device.ANCMode.Valid {
+		if ancMode == types.ANCModeUnknown {
+			switch device.ANCMode.Value {
+			case types.ANCModeOff:
+				ancMode = types.ANCModeTransparency
+			case types.ANCModeTransparency:
+				ancMode = types.ANCModeAdaptive
+			case types.ANCModeAdaptive:
+				ancMode = types.ANCModeOn
+			case types.ANCModeOn:
+				ancMode = types.ANCModeOff
+			}
+		}
+
+		if err := state.SetANCMode(device, ancMode); err != nil {
+			fmt.Fprintln(os.Stderr, "[setAncModeCommand] Error:", err)
+			os.Exit(1)
+		}
+
+		if err := state.UpdateDevices(); err != nil {
+			fmt.Fprintln(os.Stderr, "[setAncModeCommand] Error:", err)
+			os.Exit(1)
+		}
+
+		device, _ = state.Devices.Get(state.SelectedDevice)
+		response := fmt.Sprintf("Switched to ANC-Mode: %s", device.ANCMode.Value)
+
+		state.SendNotify(response, 2, device.Name)
+		fmt.Fprintf(os.Stdout, "[%s]: %s\n", device.Name, response)
+	} else {
+		fmt.Fprintf(os.Stderr, "Device '%s' doesn't support ANC!\n", device.Name)
+	}
+
+	os.Exit(0)
+}
diff --git a/src/cmdToggleFeature.go b/src/cmdToggleFeature.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+	"encoding/hex"
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/types"
+)
+
+func (state State) toggleFeatureCommand(feature string, value types.Bool) {
+	if feature == "" {
+		fmt.Fprint(os.Stderr, "[toggleFeatureCommand] Error: No feature supplied!\n\nUse `--help` for a list of available features.\n", feature)
+		os.Exit(1)
+	}
+
+	if _, ok := AACPControlCommand[feature]; !ok {
+		fmt.Fprintf(os.Stderr, "[toggleFeatureCommand] Error: Unknown feature: %s!\nUse `--help` for a list of available features.\n", feature)
+		os.Exit(1)
+	}
+
+	device, err := state.Devices.Get(state.SelectedDevice)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "[toggleFeatureCommand] Error: Device '%s' is not connected!\n", state.SelectedDevice)
+		os.Exit(1)
+	}
+
+	cmd := AACPControlCommandPacket
+	cmd[6] = AACPControlCommand[feature]
+	cmd[7] = AACPControlCommandBool[bool(value)]
+
+	if err := state.SendRawCommand(device, hex.EncodeToString(cmd)); err != nil {
+		fmt.Fprintln(os.Stderr, "[toggleFeatureCommand] Error:", err)
+		os.Exit(1)
+	}
+
+	os.Exit(0)
+}
diff --git a/src/go.mod b/src/go.mod
@@ -0,0 +1,5 @@
+module git.zaphyra.eu/airpodsctl
+
+go 1.24.4
+
+require github.com/godbus/dbus/v5 v5.1.0
diff --git a/src/go.sum b/src/go.sum
@@ -0,0 +1,2 @@
+github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
+github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
diff --git a/src/helpStrings.go b/src/helpStrings.go
@@ -0,0 +1,29 @@
+package main
+
+const usageStr = `airpodsctl v%s (%s)
+Control your Apple AirPods from your non-apple computer
+
+USAGE
+  $ airpodsctl %s [OPTIONS]
+%s
+GENERAL OPTIONS
+`
+
+const commandHelpStr = `
+COMMANDS
+  Available Commands:
+    listDevices                      List all connected devices
+    dumpDeviceState                  Output the state of a device
+    toggleAncMode                    Set active ANC-Mode (toggles between all available ANC-Modes if no mode given)
+    toggleFeature                    Enable or disable a feature
+    monitor                          Continuously monitor a device and output its state
+
+  Use "airpodsctl <COMMAND> --help" for more information about a command.
+`
+
+const featuresHelpStr = `Available features:
+  OneBudAnc                 Allow ANC when only one bud is used
+  VolumeSwipe               Control volume with swipes on buds
+  AdaptiveVolume            Adaptive volume automatically adjusts the media volume based your environment
+  ConversationalAwareness   Conversational awareness activates transparency mode when you start to speak
+  AllowANCOff               Allow the ANC-mode 'off', otherwise only 'on', 'transparency' and 'adaptive' are allowed`
diff --git a/src/helpers/cli.go b/src/helpers/cli.go
@@ -0,0 +1,9 @@
+package helpers
+
+import (
+	"fmt"
+)
+
+func BoldText(text string) string {
+	return fmt.Sprintf("\033[1m%s\033[0m", text)
+}
diff --git a/src/helpers/math.go b/src/helpers/math.go
@@ -0,0 +1,18 @@
+package helpers
+
+func Divmod(numerator, denominator int) (quotient, remainder int) {
+	quotient = numerator / denominator // integer division, decimals are truncated
+	remainder = numerator % denominator
+	return
+}
+
+func Mean(data []float64) float64 {
+    if len(data) == 0 {
+        return 0
+    }
+    var sum float64
+    for _, d := range data {
+        sum += d
+    }
+    return sum / float64(len(data))
+}
diff --git a/src/main.go b/src/main.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"os"
+	"os/signal"
+	"slices"
+	"syscall"
+
+	"git.zaphyra.eu/airpodsctl/helpers"
+	"git.zaphyra.eu/airpodsctl/types"
+)
+
+func init() {
+	c := make(chan os.Signal)
+	signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-c
+		fmt.Fprint(os.Stdout, "\033[?25h")
+		os.Exit(0)
+	}()
+}
+
+func main() {
+	state, errStateInit := InitState()
+	if errStateInit != nil {
+		fmt.Fprintln(os.Stderr, "Failed to init state:", errStateInit)
+		os.Exit(1)
+	}
+
+	defer state.Close()
+
+	flag.Usage = func() {
+		command := "<COMMAND>"
+		commandHelp := commandHelpStr
+
+		if len(flag.Args()) > 1 {
+			command = flag.Arg(0)
+			commandHelp = ""
+		}
+
+		fmt.Fprintf(os.Stderr, usageStr, "0.0.0", "2025-08-11", command, commandHelp)
+		flag.PrintDefaults()
+	}
+
+	flag.StringVar(&state.SelectedDevice, "device", "", "`<macAddress>` of the device that should be used (default: first device found)")
+	flag.BoolVar(&state.SendNotifications, "notify", false, "Enable notifications")
+	flag.BoolVar(&state.DebugOutput, "debug", false, "Enable debug-output")
+	flag.Var(&state.OutputFormat, "format", "`<format>` to use for output (default: cli)\nSupported formats: {cli|waybar}")
+	flag.BoolFunc("help", "Print this help message", func(str string) error {
+		flag.Usage()
+		os.Exit(0)
+		return nil
+	})
+
+	flag.Parse()
+
+	commandArgs := flag.NewFlagSet("", flag.ExitOnError)
+
+	commandArgs.Usage = func() {
+		flag.Usage()
+		fmt.Fprint(os.Stderr, "\nCOMMAND OPTIONS\n")
+		commandArgs.PrintDefaults()
+		fmt.Fprint(os.Stderr, "\n")
+	}
+
+	if len(flag.Args()) < 1 {
+		fmt.Fprintln(os.Stdout, "No command supplied!\n\nUse --help for a list of available commands.")
+		os.Exit(0)
+	}
+
+	switch flag.Arg(0) {
+	case "listDevices":
+		state.listDevicesCommand()
+
+	case "dumpDeviceState":
+		state.checkSelectedDevice()
+		state.dumpDeviceStateCommand()
+
+	case "toggleAncMode":
+		var ancMode types.ANCMode
+		commandArgs.Var(&ancMode, "mode", "`<ancMode>` that should be set\nValid modes: {on|adaptive|transparency|off}")
+		commandArgs.Parse(flag.Args()[1:])
+
+		state.checkSelectedDevice()
+		state.toggleAncModeCommand(ancMode)
+
+	case "toggleFeature":
+		var feature string
+		var value types.Bool
+
+		commandArgs.Var(&value, "value", "`<value>` that should be set\nValid values: {on|enable|off|disable}")
+		commandArgs.Func("feature", "`<feature>` that should be modified\n"+featuresHelpStr, func(str string) (err error) {
+			switch str {
+			case "":
+				err = errors.New("No feature supplied!\n")
+			case "OneBudAnc":
+				feature = "OneBudANCMode"
+			case "VolumeSwipe":
+				feature = "VolumeSwipeConfig"
+			case "AdaptiveVolume":
+				feature = "AdaptiveVolumeConfig"
+			case "ConversationalAwareness":
+				feature = "ConversationDetectConfig"
+			case "AllowANCOff":
+				feature = "AllowANCOffConfig"
+			default:
+				err = fmt.Errorf("Unknown Feature: %s\n", str)
+			}
+			return
+		})
+
+		commandArgs.Parse(flag.Args()[1:])
+
+		state.checkSelectedDevice()
+		state.toggleFeatureCommand(feature, value)
+
+	case "monitor":
+		state.checkSelectedDevice()
+		state.monitorCommand()
+
+	default:
+		fmt.Fprintf(os.Stdout, helpers.BoldText("Unknown command: %s")+"\n%s\n", flag.Arg(0), commandHelpStr)
+	}
+}
+
+func (state *State) checkSelectedDevice() {
+	if state.SelectedDevice == "" {
+		firstConnectedDevice := slices.IndexFunc(state.Devices, func(device types.Device) bool {
+			return bool(device.Connected)
+		})
+
+		if firstConnectedDevice != -1 {
+			state.SelectedDevice = state.Devices[firstConnectedDevice].Address
+		} else {
+			fmt.Fprintln(os.Stderr, "Error: No device connected!")
+			os.Exit(1)
+		}
+	}
+}
diff --git a/src/state.go b/src/state.go
@@ -0,0 +1,160 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/types"
+
+	"github.com/godbus/dbus/v5"
+)
+
+type State struct {
+	BusConn            *dbus.Conn
+	Signals            chan *dbus.Signal
+	Devices            types.Devices
+	SelectedDevice     string
+	DebugOutput        bool
+	OutputFormat       types.OutputFormat
+	SendNotifications  bool
+	LastNotificationId int
+}
+
+func InitState() (State, error) {
+	dbusConn, errBusConn := dbus.ConnectSessionBus()
+	if errBusConn != nil {
+		return State{}, fmt.Errorf("[InitState] Failed to connect to session bus: %s", errBusConn)
+	}
+
+	// create state object
+	state := State{
+		BusConn:            dbusConn,
+		Signals:            make(chan *dbus.Signal, 10),
+		Devices:            types.Devices{},
+		DebugOutput:        false,
+		OutputFormat:       types.FormatCLI,
+		SendNotifications:  false,
+		LastNotificationId: 0,
+	}
+
+	// initial devices poll
+	state.UpdateDevices()
+
+	return state, nil
+}
+
+// Close cleans up and shuts down signal delivery loop.
+func (state *State) Close() error {
+	// remove signal reception
+	state.BusConn.RemoveSignal(state.Signals)
+
+	// unregister on dbus:
+	state.BusConn.RemoveMatchSignal(
+		dbus.WithMatchObjectPath("/org/kairpods/manager"),
+	)
+
+	// disconnect from dbus
+	return state.BusConn.Close()
+}
+
+func (state *State) PrintDebug(message string) {
+	if state.DebugOutput {
+		fmt.Fprint(os.Stderr, message)
+	}
+}
+
+func (state *State) UpdateDevices() error {
+	var devicesStr string
+
+	obj := state.BusConn.Object("org.kairpods", "/org/kairpods/manager")
+	if err := obj.Call("org.kairpods.manager.GetDevices", 0).Store(&devicesStr); err != nil {
+		return fmt.Errorf("[UpdateDevices] Error reading Devices from dbus: %s", err)
+	}
+
+	if err := json.Unmarshal([]byte(devicesStr), &state.Devices); err != nil {
+		return fmt.Errorf("[UpdateDevices] Faild to unmarshal devices-JSON: %s", err)
+	}
+
+	return nil
+}
+
+func (state *State) RegisterSignals() error {
+	// register for signals on dbus
+	errRegisterSignals := state.BusConn.AddMatchSignal(
+		dbus.WithMatchObjectPath("/org/kairpods/manager"),
+	)
+	if errRegisterSignals != nil {
+		return fmt.Errorf("[RegisterSignals] Failed to register for signals on dbus: %s", errRegisterSignals)
+	}
+
+	// connect bus to signals
+	state.BusConn.Signal(state.Signals)
+
+	return nil
+}
+
+func (state *State) SendNotify(body string, timeout int, subject string) error {
+
+	if !state.SendNotifications {
+		return nil
+	}
+
+	if subject != "" {
+		subject = "airpodsctl (" + subject + ")"
+	} else {
+		subject = "airpodsctl"
+	}
+
+	obj := state.BusConn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
+	err := obj.Call(
+		"org.freedesktop.Notifications.Notify", 0,
+		"", uint32(state.LastNotificationId), "", subject, body, []string{}, map[string]dbus.Variant{
+			"transient": dbus.MakeVariant(true),
+		}, int32(timeout*1000),
+	).Store(&state.LastNotificationId)
+
+	return err
+}
+
+func (state *State) SendRawCommand(device types.Device, command string) error {
+	var returnVal bool
+
+	obj := state.BusConn.Object("org.kairpods", "/org/kairpods/manager")
+	err := obj.Call(
+		"org.kairpods.manager.Passthrough", 0,
+		device.Address, command,
+	).Store(&returnVal)
+
+	if err != nil {
+		return err
+	}
+
+	if returnVal != true {
+		return fmt.Errorf("[SendRawCommand] Unable to send command '%s' to device '%s'", command, device.Address)
+	}
+
+	return nil
+}
+
+func (state *State) SetANCMode(device types.Device, ancMode types.ANCMode) error {
+	var returnVal bool
+
+	obj := state.BusConn.Object("org.kairpods", "/org/kairpods/manager")
+	err := obj.Call(
+		"org.kairpods.manager.SendCommand", 0,
+		device.Address, "set_noise_mode", map[string]dbus.Variant{
+			"value": dbus.MakeVariant(ancMode.GetValue()),
+		},
+	).Store(&returnVal)
+
+	if err != nil {
+		return err
+	}
+
+	if returnVal != true {
+		return fmt.Errorf("[SetANCMode] Unable to set ANC-Mode for device: %s", device.Address)
+	}
+
+	return nil
+}
diff --git a/src/types/ANCMode.go b/src/types/ANCMode.go
@@ -0,0 +1,80 @@
+package types
+
+import (
+	"encoding/json"
+	"fmt"
+)
+
+type ANCMode int
+
+const (
+	ANCModeUnknown ANCMode = iota
+	ANCModeOff
+	ANCModeTransparency
+	ANCModeAdaptive
+	ANCModeOn
+)
+
+func (ancMode *ANCMode) Set(value string) (err error) {
+	*ancMode, err = ParseANCMode(value)
+	return
+}
+
+func (ancMode ANCMode) String() string {
+	switch ancMode {
+	case ANCModeOff:
+		return "Off"
+	case ANCModeOn:
+		return "On"
+	case ANCModeTransparency:
+		return "Transparency"
+	case ANCModeAdaptive:
+		return "Adaptive"
+	}
+
+	return ""
+}
+
+func (ancMode *ANCMode) UnmarshalJSON(data []byte) error {
+	var ancModeStr string
+	if err := json.Unmarshal(data, &ancModeStr); err != nil {
+		return err
+	}
+
+	var err error
+	*ancMode, err = ParseANCMode(ancModeStr)
+
+	return err
+}
+
+func (ancMode ANCMode) GetValue() string {
+	switch ancMode {
+	case ANCModeOff:
+		return "off"
+	case ANCModeOn:
+		return "anc"
+	case ANCModeTransparency:
+		return "transparency"
+	case ANCModeAdaptive:
+		return "adaptive"
+	}
+
+	return ""
+}
+
+func ParseANCMode(ancModeStr string) (ANCMode, error) {
+	switch ancModeStr {
+	case "off":
+		return ANCModeOff, nil
+	case "on":
+		return ANCModeOn, nil
+	case "anc":
+		return ANCModeOn, nil
+	case "transparency":
+		return ANCModeTransparency, nil
+	case "adaptive":
+		return ANCModeAdaptive, nil
+	}
+
+	return ANCModeUnknown, fmt.Errorf("Faild to parse ANC-Mode: %s", ancModeStr)
+}
diff --git a/src/types/Any.go b/src/types/Any.go
@@ -0,0 +1,40 @@
+package types
+
+import (
+	"bytes"
+	"encoding/json"
+)
+
+type Any[T any] struct {
+	Set   bool
+	Valid bool
+	Value T
+}
+
+func (s Any[T]) MarshalJSON() ([]byte, error) {
+	if !s.Valid {
+		return []byte("null"), nil
+	}
+	return json.Marshal(s.Value)
+}
+
+func (s *Any[T]) UnmarshalJSON(data []byte) error {
+	s.Set = true
+	s.Valid = false
+
+	if bytes.Equal(data, []byte("null")) {
+		// The key was set to null, set value to zero/default value
+		var zero T
+		s.Value = zero
+		return nil
+	}
+
+	// The key isn't set to null
+	var v T
+	if err := json.Unmarshal(data, &v); err != nil {
+		return err
+	}
+	s.Value = v
+	s.Valid = true
+	return nil
+}
diff --git a/src/types/BatteryTTLEstimate.go b/src/types/BatteryTTLEstimate.go
@@ -0,0 +1,34 @@
+package types
+
+import (
+	"encoding/json"
+	"fmt"
+
+	"git.zaphyra.eu/airpodsctl/helpers"
+)
+
+type BatteryTTLEstimate Any[int]
+
+func (s *BatteryTTLEstimate) UnmarshalJSON(data []byte) error {
+	var v Any[int]
+	if err := json.Unmarshal(data, &v); err != nil {
+		return err
+	}
+
+	*s = BatteryTTLEstimate{
+		Set: v.Set,
+		Valid: v.Valid,
+		Value: v.Value,
+	}
+
+	return nil
+}
+
+func (batteryTTLEstimate BatteryTTLEstimate) String() string {
+	if batteryTTLEstimate.Valid {
+		hours, minutes := helpers.Divmod(batteryTTLEstimate.Value, 60)
+		return fmt.Sprintf("%dh %dm", hours, minutes)
+	}
+
+	return ""
+}
diff --git a/src/types/Bool.go b/src/types/Bool.go
@@ -0,0 +1,37 @@
+package types
+
+import (
+	"fmt"
+)
+
+type Bool bool
+
+func (boolean *Bool) Set(value string) (err error) {
+	*boolean, err = ParseBool(value)
+	return
+}
+
+func (value Bool) String() string {
+	if value {
+		return "Yes"
+	}
+	return "No"
+}
+
+func (value Bool) FormatFeatureString() string {
+	if value {
+		return "Enabled"
+	}
+	return "Disabled"
+}
+
+func ParseBool(str string) (Bool, error) {
+	switch str {
+	case "disable", "off":
+		return false, nil
+	case "enable", "on":
+		return true, nil
+	}
+
+	return false, fmt.Errorf("Faild to parse Boolean: %s\n", str)
+}
diff --git a/src/types/Device.go b/src/types/Device.go
@@ -0,0 +1,36 @@
+package types
+
+type BatteryState struct {
+	Charging bool `json:"charging"`
+	Level    int  `json:"level"`
+}
+
+type EarDetectionState  struct {
+	LeftInEar  bool `json:"left_in_ear"`
+	RightInEar bool `json:"right_in_ear"`
+}
+
+type Device struct {
+	Address string `json:"address"`
+	Battery struct {
+		Case      Any[BatteryState] `json:"case"`
+		Headphone Any[BatteryState] `json:"headphone"`
+		LeftBud   Any[BatteryState] `json:"left"`
+		RightBud  Any[BatteryState] `json:"right"`
+	} `json:"battery"`
+	BatteryTTLEstimate BatteryTTLEstimate     `json:"battery_ttl_estimate"`
+	Connected          Bool                   `json:"connected"`
+	EarDetection       Any[EarDetectionState] `json:"ear_detection"`
+	Features struct {
+		Num35              Any[Bool] `json:"35"`
+		ThreeE             Any[Bool] `json:"3e"`
+		AdaptiveVolume     Any[Bool] `json:"adaptive_volume"`
+		Conversational     Any[Bool] `json:"conversational"`
+		HearingAidSettings Any[Bool] `json:"hearing_aid_settings"`
+		OneBudAnc          Any[Bool] `json:"one_bud_anc"`
+		Ssl                Any[Bool] `json:"ssl"`
+		VolumeSwipe        Any[Bool] `json:"volume_swipe"`
+	} `json:"features"`
+	Name      string       `json:"name"`
+	ANCMode   Any[ANCMode] `json:"noise_mode"`
+}
diff --git a/src/types/Devices.go b/src/types/Devices.go
@@ -0,0 +1,24 @@
+package types
+
+import (
+	"fmt"
+	"slices"
+)
+
+type Devices []Device
+
+func (devices Devices) Get(macAddr string) (Device, error) {
+	index := devices.GetIndex(macAddr)
+
+	if index == -1 {
+		return Device{}, fmt.Errorf("Faild to get device:", macAddr)
+	}
+
+	return devices[index], nil
+}
+
+func (devices Devices) GetIndex(macAddr string) int {
+	return slices.IndexFunc(devices, func(device Device) bool {
+		return device.Address == macAddr
+	})
+}
diff --git a/src/types/OutputFormat.go b/src/types/OutputFormat.go
@@ -0,0 +1,36 @@
+package types
+
+import (
+	"fmt"
+)
+
+type OutputFormat int
+
+const (
+    FormatUnknown OutputFormat = iota
+    FormatCLI
+    FormatWaybar
+)
+
+func (outputFormat *OutputFormat) Set(value string) (err error) {
+	*outputFormat, err = ParseOutputFormat(value)
+	return
+}
+
+func (outputFormat *OutputFormat) String() string {
+	switch *outputFormat {
+		case FormatUnknown: return "unknown"
+		case FormatCLI:     return "cli"
+		case FormatWaybar:  return "waybar"
+	}
+	return ""
+}
+
+func ParseOutputFormat(formatStr string) (OutputFormat, error) {
+	switch formatStr {
+		case "cli":    return FormatCLI, nil
+		case "waybar": return FormatWaybar, nil
+	}
+
+	return FormatUnknown, fmt.Errorf("Faild to parse output-format: %s\n", formatStr)
+}
diff --git a/src/types/StringSlice.go b/src/types/StringSlice.go
@@ -0,0 +1,27 @@
+package types
+
+import (
+	"strings"
+	"encoding/json"
+)
+
+type StringSlice  struct {
+	Value []string
+}
+
+func (strSlice StringSlice) String() string {
+	return strSlice.ConcatString("")
+}
+
+func (strSlice StringSlice) MarshalJSON() ([]byte, error) {
+	return json.Marshal(strSlice.String())
+}
+
+func (strSlice *StringSlice) Add(item string) []string {
+	strSlice.Value = append(strSlice.Value, item)
+	return strSlice.Value
+}
+
+func (strSlice StringSlice) ConcatString(joiner string) string {
+	return strings.Join(strSlice.Value, joiner)
+}
diff --git a/src/types/formatDeviceCli.go b/src/types/formatDeviceCli.go
@@ -0,0 +1,86 @@
+package types
+
+import (
+	"fmt"
+
+	"git.zaphyra.eu/airpodsctl/helpers"
+)
+
+func (device Device) FormatCLIString() (string, int) {
+	result := StringSlice{}
+
+	result.Add(fmt.Sprintf(helpers.BoldText("============[%s]============"), device.Name))
+	result.Add(fmt.Sprintf(helpers.BoldText("󰌹 Connected:")+"\t   %s", device.Connected.String()))
+
+	if device.ANCMode.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("󰋌 ANC-Mode:")+"\t   %s", device.ANCMode.Value))
+	}
+
+	if device.BatteryTTLEstimate.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("󱧦 Time remaining:")+"  ~%s", device.BatteryTTLEstimate))
+	}
+
+	if device.Battery.Headphone.Valid {
+		var chargeSymbol string
+		if device.Battery.Headphone.Value.Charging {
+			chargeSymbol = " "
+		}
+
+		result.Add(fmt.Sprintf(helpers.BoldText("󰁹  Headphones:")+"\t   %d% %#s", device.Battery.Headphone.Value.Level, chargeSymbol))
+	}
+
+	if device.Battery.LeftBud.Valid || device.Battery.RightBud.Valid {
+		var batteryStateBuds StringSlice
+
+		if device.Battery.LeftBud.Valid {
+			var chargeSymbol string
+			if device.Battery.LeftBud.Value.Charging {
+				chargeSymbol = " "
+			}
+
+			batteryStateBuds.Add(fmt.Sprintf("L: %d%%%#s", device.Battery.LeftBud.Value.Level, chargeSymbol))
+		}
+
+		if device.Battery.RightBud.Valid {
+			var chargeSymbol string
+			if device.Battery.RightBud.Value.Charging {
+				chargeSymbol = " "
+			}
+
+			batteryStateBuds.Add(fmt.Sprintf("R: %d%%%#s", device.Battery.RightBud.Value.Level, chargeSymbol))
+		}
+
+		result.Add(fmt.Sprintf(helpers.BoldText("󰁹 Buds:")+"\t\t   %#s", batteryStateBuds.ConcatString("  ")))
+	}
+
+	if device.Battery.Case.Valid {
+		var chargeSymbol = ""
+		if device.Battery.Case.Value.Charging {
+			chargeSymbol = "<span size=\"smaller\" rise=\"1pt\"></span>"
+		}
+
+		result.Add(fmt.Sprintf(helpers.BoldText("󰁹 Case:")+"\t\t   %d%%%#s", device.Battery.Case.Value.Level, chargeSymbol))
+	}
+
+	result.Add(fmt.Sprintf("\n%s", helpers.BoldText("Features:")))
+
+	if device.Features.AdaptiveVolume.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("Adaptive Volume:")+"\t   %s", device.Features.AdaptiveVolume.Value.FormatFeatureString()))
+	}
+
+	if device.Features.OneBudAnc.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("One Bud ANC:")+"\t\t   %s", device.Features.OneBudAnc.Value.FormatFeatureString()))
+	}
+
+	if device.Features.Conversational.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("Conversational-Awareness:")+"  %s", device.Features.Conversational.Value.FormatFeatureString()))
+	}
+
+	if device.Features.VolumeSwipe.Valid {
+		result.Add(fmt.Sprintf(helpers.BoldText("Volume-Swipe:")+"\t\t   %s", device.Features.VolumeSwipe.Value.FormatFeatureString()))
+	}
+
+	result.Add("")
+
+	return result.ConcatString("\n"), len(result.Value)
+}
diff --git a/src/types/formatDeviceWaybar.go b/src/types/formatDeviceWaybar.go
@@ -0,0 +1,95 @@
+package types
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+
+	"git.zaphyra.eu/airpodsctl/helpers"
+)
+
+func (device Device) FormatWaybarString() string {
+	type WaybarFormat struct {
+		Class      string      `json:"class,omitempty"`
+		Percentage int         `json:"percentage"`
+		Text       StringSlice `json:"text,omitempty"`
+		Tooltip    string      `json:"tooltip,omitempty"`
+	}
+
+	// fmt.Printf("%+v\n", device)
+	output := WaybarFormat{}
+	tooltip := StringSlice{}
+
+	tooltip.Add(fmt.Sprintf("Device:\t\t%s", device.Name))
+	tooltip.Add(fmt.Sprintf("Connected:\t%s", device.Connected))
+
+	if device.ANCMode.Valid {
+		tooltip.Add(fmt.Sprintf("ANC-Mode:\t%s", device.ANCMode.Value))
+	}
+
+	if device.BatteryTTLEstimate.Valid {
+		tooltip.Add(fmt.Sprintf("\n󱧦 ~%s remaining", device.BatteryTTLEstimate))
+	}
+
+	if device.Battery.Headphone.Valid {
+		var chargeSymbol string
+		if device.Battery.Headphone.Value.Charging {
+			chargeSymbol = " <span size=\"smaller\" rise=\"1pt\"></span>"
+		}
+
+		tooltip.Add(fmt.Sprintf("󰁹  Headphones: %d% %#s", device.Battery.Headphone.Value.Level, chargeSymbol))
+		output.Percentage = device.Battery.Headphone.Value.Level
+	}
+
+	if device.Battery.LeftBud.Valid || device.Battery.RightBud.Valid {
+		var batteryPercentage []float64
+		var batteryStateBuds StringSlice
+
+		if device.Battery.LeftBud.Valid {
+			var chargeSymbol string
+			if device.Battery.LeftBud.Value.Charging {
+				chargeSymbol = " <span size=\"smaller\" rise=\"1pt\"></span>"
+			}
+
+			batteryStateBuds.Add(fmt.Sprintf("<small>L:</small> %d%%%#s", device.Battery.LeftBud.Value.Level, chargeSymbol))
+			batteryPercentage = append(batteryPercentage, float64(device.Battery.LeftBud.Value.Level))
+		}
+
+		if device.Battery.RightBud.Valid {
+			var chargeSymbol string
+			if device.Battery.RightBud.Value.Charging {
+				chargeSymbol = " <span size=\"smaller\" rise=\"1pt\"></span>"
+			}
+
+			batteryStateBuds.Add(fmt.Sprintf("<small>R:</small> %d%%%#s", device.Battery.RightBud.Value.Level, chargeSymbol))
+			batteryPercentage = append(batteryPercentage, float64(device.Battery.RightBud.Value.Level))
+		}
+
+		tooltip.Add(fmt.Sprintf("󰁹  Buds: %#s", batteryStateBuds.ConcatString("  ")))
+		output.Percentage = int(helpers.Mean(batteryPercentage))
+	}
+
+	if device.Battery.Case.Valid {
+		var chargeSymbol = ""
+		if device.Battery.Case.Value.Charging {
+			chargeSymbol = "<span size=\"smaller\" rise=\"1pt\"></span>"
+		}
+
+		tooltip.Add(fmt.Sprintf("󰁹  Case: %d%%%#s", device.Battery.Case.Value.Level, chargeSymbol))
+	}
+
+	output.Tooltip = tooltip.ConcatString("\n")
+
+	jsonOutputBuffer := &bytes.Buffer{}
+	jsonEncoder := json.NewEncoder(jsonOutputBuffer)
+	jsonEncoder.SetEscapeHTML(false)
+
+	err := jsonEncoder.Encode(output)
+	if err != nil {
+		fmt.Fprintln(os.Stderr, "[FormatWaybarString] Unable to marshal JSON: %s", err)
+		return ""
+	}
+
+	return string(jsonOutputBuffer.Bytes())
+}