mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 16:18:24 +02:00
@@ -0,0 +1,71 @@
|
||||
// Package bluetooth wraps bluetoothctl to read and control Bluetooth state
|
||||
// (controller power, device pairing/connection). Every exported function
|
||||
// shells out to bluetoothctl in its non-interactive single-command mode;
|
||||
// there is no direct D-Bus/BlueZ integration.
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("bluetoothctl not found in PATH")
|
||||
ErrNoController = errors.New("no Bluetooth controller found")
|
||||
)
|
||||
|
||||
const defaultTimeout = 15 * time.Second
|
||||
|
||||
// CheckAvailable reports whether bluetoothctl is installed and a Bluetooth
|
||||
// controller is reachable. Call it before using the rest of the package.
|
||||
func CheckAvailable() error {
|
||||
if _, err := exec.LookPath("bluetoothctl"); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
out, err := run(defaultTimeout, "list")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrNoController, err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return ErrNoController
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes bluetoothctl in single-command (non-interactive) mode.
|
||||
// bluetoothctl writes both normal output and error messages to stdout
|
||||
// (colored with ANSI escapes), so errors are extracted from there rather
|
||||
// than stderr.
|
||||
func run(timeout time.Duration, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "bluetoothctl", args...) // #nosec G204 -- args are fixed bluetoothctl subcommands or user-supplied device addresses, never shell-interpreted
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
out := ansi.Strip(stdout.String())
|
||||
if err != nil {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
msg := strings.TrimSpace(out)
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(stderr.String())
|
||||
}
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return "", fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), msg)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package bluetooth
|
||||
|
||||
import "strings"
|
||||
|
||||
// ControllerInfo is the state of the default Bluetooth adapter, as reported
|
||||
// by `bluetoothctl show`.
|
||||
type ControllerInfo struct {
|
||||
Address string
|
||||
Name string
|
||||
Powered bool
|
||||
Discoverable bool
|
||||
Pairable bool
|
||||
Discovering bool
|
||||
}
|
||||
|
||||
// ShowController returns the state of the default Bluetooth controller.
|
||||
func ShowController() (ControllerInfo, error) {
|
||||
out, err := run(defaultTimeout, "show")
|
||||
if err != nil {
|
||||
return ControllerInfo{}, err
|
||||
}
|
||||
|
||||
var c ControllerInfo
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if rest, ok := strings.CutPrefix(line, "Controller "); ok {
|
||||
if fields := strings.Fields(rest); len(fields) > 0 {
|
||||
c.Address = fields[0]
|
||||
}
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "Name":
|
||||
c.Name = value
|
||||
case "Powered":
|
||||
c.Powered = value == "yes"
|
||||
case "Discoverable":
|
||||
c.Discoverable = value == "yes"
|
||||
case "Pairable":
|
||||
c.Pairable = value == "yes"
|
||||
case "Discovering":
|
||||
c.Discovering = value == "yes"
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// SetPower turns the Bluetooth controller on or off entirely.
|
||||
func SetPower(on bool) error {
|
||||
state := "off"
|
||||
if on {
|
||||
state = "on"
|
||||
}
|
||||
_, err := run(defaultTimeout, "power", state)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package bluetooth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// actionTimeout bounds connect/pair calls, which can take longer than a
|
||||
// plain query while BlueZ negotiates with the remote device.
|
||||
actionTimeout = 30 * time.Second
|
||||
scanBuffer = 10 * time.Second
|
||||
)
|
||||
|
||||
// Device is a Bluetooth device known to BlueZ: either paired, or seen
|
||||
// during a recent scan.
|
||||
type Device struct {
|
||||
Address string
|
||||
Name string
|
||||
Paired bool
|
||||
Bonded bool
|
||||
Trusted bool
|
||||
Connected bool
|
||||
Blocked bool
|
||||
Icon string // freedesktop icon name, e.g. "input-mouse"; empty if unknown
|
||||
Battery int // 0-100, -1 if unknown
|
||||
}
|
||||
|
||||
// DeviceDetail extends Device with the extra fields shown on the "device
|
||||
// info" screen.
|
||||
type DeviceDetail struct {
|
||||
Device
|
||||
Modalias string
|
||||
UUIDs []string // human-readable service labels
|
||||
}
|
||||
|
||||
// parseDeviceLines parses the "Device <address> <name>" lines printed by
|
||||
// `bluetoothctl devices` into address -> name.
|
||||
func parseDeviceLines(out string) map[string]string {
|
||||
names := make(map[string]string)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
rest, ok := strings.CutPrefix(line, "Device ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addr, name, ok := strings.Cut(rest, " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
names[addr] = name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func deviceSet(filter string) (map[string]bool, error) {
|
||||
args := []string{"devices"}
|
||||
if filter != "" {
|
||||
args = append(args, filter)
|
||||
}
|
||||
out, err := run(defaultTimeout, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for addr := range parseDeviceLines(out) {
|
||||
set[addr] = true
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// ListDevices returns every device BlueZ currently knows about: paired
|
||||
// devices plus anything seen during a recent scan. Paired devices get an
|
||||
// extra `info` lookup for icon/battery/blocked state; unpaired ones don't,
|
||||
// to keep a large scan result snappy.
|
||||
func ListDevices() ([]Device, error) {
|
||||
out, err := run(defaultTimeout, "devices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names := parseDeviceLines(out)
|
||||
|
||||
paired, err := deviceSet("Paired")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connected, err := deviceSet("Connected")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trusted, err := deviceSet("Trusted")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
devices := make([]Device, 0, len(names))
|
||||
for addr, name := range names {
|
||||
d := Device{
|
||||
Address: addr,
|
||||
Name: name,
|
||||
Paired: paired[addr],
|
||||
Connected: connected[addr],
|
||||
Trusted: trusted[addr],
|
||||
Battery: -1,
|
||||
}
|
||||
if d.Paired {
|
||||
if detail, err := Info(addr); err == nil {
|
||||
d.Icon = detail.Icon
|
||||
d.Battery = detail.Battery
|
||||
d.Bonded = detail.Bonded
|
||||
d.Blocked = detail.Blocked
|
||||
if detail.Name != "" {
|
||||
d.Name = detail.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if d.Name == "" {
|
||||
d.Name = d.Address
|
||||
}
|
||||
devices = append(devices, d)
|
||||
}
|
||||
return devices, nil
|
||||
}
|
||||
|
||||
// Info fetches full detail for one device via `bluetoothctl info`.
|
||||
func Info(address string) (DeviceDetail, error) {
|
||||
out, err := run(defaultTimeout, "info", address)
|
||||
if err != nil {
|
||||
return DeviceDetail{}, err
|
||||
}
|
||||
|
||||
d := DeviceDetail{Device: Device{Address: address, Battery: -1}}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Device ") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
switch key {
|
||||
case "Name":
|
||||
d.Name = value
|
||||
case "Icon":
|
||||
d.Icon = value
|
||||
case "Paired":
|
||||
d.Paired = value == "yes"
|
||||
case "Bonded":
|
||||
d.Bonded = value == "yes"
|
||||
case "Trusted":
|
||||
d.Trusted = value == "yes"
|
||||
case "Blocked":
|
||||
d.Blocked = value == "yes"
|
||||
case "Connected":
|
||||
d.Connected = value == "yes"
|
||||
case "Modalias":
|
||||
d.Modalias = value
|
||||
case "UUID":
|
||||
if i := strings.Index(value, "("); i >= 0 {
|
||||
value = strings.TrimSpace(value[:i])
|
||||
}
|
||||
if value != "" {
|
||||
d.UUIDs = append(d.UUIDs, value)
|
||||
}
|
||||
case "Battery Percentage":
|
||||
if i, j := strings.Index(value, "("), strings.Index(value, ")"); i >= 0 && j > i {
|
||||
if n, err := strconv.Atoi(value[i+1 : j]); err == nil {
|
||||
d.Battery = n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Scan puts the controller into discovery mode for the given number of
|
||||
// seconds, then returns. Newly seen devices show up in a subsequent
|
||||
// ListDevices call.
|
||||
func Scan(seconds int) error {
|
||||
_, err := run(time.Duration(seconds)*time.Second+scanBuffer, "--timeout", strconv.Itoa(seconds), "scan", "on")
|
||||
return err
|
||||
}
|
||||
|
||||
// Pair initiates pairing with a device. Because non-interactive
|
||||
// bluetoothctl has no agent to answer PIN/confirmation prompts, this only
|
||||
// succeeds for "just works" devices (most mice, keyboards, headsets).
|
||||
func Pair(address string) error {
|
||||
_, err := run(actionTimeout, "pair", address)
|
||||
return err
|
||||
}
|
||||
|
||||
// Connect brings up all profiles for an already-paired (or just-paired)
|
||||
// device.
|
||||
func Connect(address string) error {
|
||||
_, err := run(actionTimeout, "connect", address)
|
||||
return err
|
||||
}
|
||||
|
||||
// Disconnect disconnects a device without forgetting it.
|
||||
func Disconnect(address string) error {
|
||||
_, err := run(defaultTimeout, "disconnect", address)
|
||||
return err
|
||||
}
|
||||
|
||||
// SetTrusted marks a device as trusted (allowed to reconnect without
|
||||
// confirmation) or not.
|
||||
func SetTrusted(address string, trust bool) error {
|
||||
verb := "untrust"
|
||||
if trust {
|
||||
verb = "trust"
|
||||
}
|
||||
_, err := run(defaultTimeout, verb, address)
|
||||
return err
|
||||
}
|
||||
|
||||
// Remove deletes a paired device's profile, or drops an unpaired device
|
||||
// from BlueZ's scan cache.
|
||||
func Remove(address string) error {
|
||||
_, err := run(defaultTimeout, "remove", address)
|
||||
return err
|
||||
}
|
||||
Reference in New Issue
Block a user