package main import ( "fmt" "regexp" "sort" "strconv" "strings" "time" ) var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`) func addressOf(args map[string]any) (string, error) { a, err := text(args, "address") if err != nil { return "", err } if !macAddress.MatchString(a) { return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a) } return strings.ToUpper(a), nil } var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`) // btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status. var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`) // bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the // account act, and if it refuses, the act is run through sudo -n. func bt(timeout time.Duration, args ...string) (string, error) { c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout} r := run(c) if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") { c.Root = true r = run(c) } out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "") if r.Error != "" { return out, failure(c, r) } if strings.Contains(out, "No default controller available") { return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running") } if m := btFailed.FindString(out); m != "" || r.Status != 0 { said := strings.TrimSpace(out) if said == "" { said = fmt.Sprintf("exit status %d", r.Status) } return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000)) } return out, nil } // fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value. func fields(s string) map[string]string { out := map[string]string{} for _, l := range strings.Split(s, "\n") { if !strings.HasPrefix(l, "\t") { continue } k, v, ok := strings.Cut(strings.TrimSpace(l), ":") if !ok { continue } if _, seen := out[k]; !seen { out[k] = strings.TrimSpace(v) } } return out } func yes(v string) bool { return v == "yes" } // ControllerAnswer is what bluetooth_controller answers. type ControllerAnswer struct { Address string `json:"address"` Name string `json:"name"` Alias string `json:"alias"` Powered bool `json:"powered"` PowerState string `json:"power_state,omitempty"` Discoverable bool `json:"discoverable"` Pairable bool `json:"pairable"` Discovering bool `json:"discovering"` } // Controller answers the default controller. func Controller() (ControllerAnswer, error) { out, err := bt(CallTimeout, "show") if err != nil { return ControllerAnswer{}, err } c := ControllerAnswer{} for _, l := range strings.Split(out, "\n") { if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" { c.Address = f[1] break } } if c.Address == "" { return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500)) } f := fields(out) c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"] c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"]) return c, nil } // Power switches the controller on or off. func Power(on bool) (map[string]any, error) { word := "off" if on { word = "on" } if _, err := bt(CallTimeout, "power", word); err != nil { return nil, err } c, err := Controller() if err != nil { return nil, err } return map[string]any{"powered": c.Powered, "asked": word}, nil } // Device is one device bluez knows. type Device struct { Address string `json:"address"` Name string `json:"name"` Icon string `json:"kind,omitempty"` Paired bool `json:"paired"` Bonded bool `json:"bonded"` Trusted bool `json:"trusted"` Blocked bool `json:"blocked"` Connected bool `json:"connected"` Battery *int `json:"battery_percent,omitempty"` RSSI *int `json:"rssi,omitempty"` } var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`) // number reads "0x50 (80)" or "-62" as a number. func number(v string) *int { if m := inParens.FindStringSubmatch(v); m != nil { v = m[1] } n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil { return nil } return &n } // deviceInfo asks bluez for one device. func deviceInfo(address string) (Device, error) { out, err := bt(CallTimeout, "info", address) if err != nil { return Device{}, err } f := fields(out) d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]), Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])} if d.Name == "" { d.Name = f["Alias"] } if v, ok := f["Battery Percentage"]; ok { d.Battery = number(v) } if v, ok := f["RSSI"]; ok { d.RSSI = number(v) } return d, nil } // listed reads "Device