commit 568d36744a20b45e898ece4fd478fa2076b3654b
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Tue, 12 Aug 2025 19:48:25 +0200
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
|
127
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
80
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
86
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
|
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()) +}