diff --git a/modules/bluetooth/README.md b/modules/bluetooth/README.md
new file mode 100644
index 0000000..08c6a71
--- /dev/null
+++ b/modules/bluetooth/README.md
@@ -0,0 +1,53 @@
+# bluetooth
+
+Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
+
+## Owns
+
+| what | where |
+|---|---|
+| the Bluetooth stack and its daemon | package `bluez` |
+| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
+| the daemon, running and enabled | `bluetooth.service` |
+
+All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
+(every setting commented out). The module states nothing in it, so it declares nothing there.
+
+## Improves
+
+- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
+ installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
+ for the next clean-up to take, and Bluetooth with it.
+- **An owner for the daemon**, running and enabled on both today with nothing recording why.
+- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
+ any machine, without the applet.
+
+## Tools
+
+All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
+the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
+whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
+bluetoothctl's exit status.
+
+| tool | what |
+|---|---|
+| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
+| `bluetooth_power` (r/a) | read, or switch the controller on or off |
+| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
+| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
+| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
+| `bluetooth_trust` (a) | trust, or untrust |
+| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
+| `bluetooth_remove` (a) | forget a device |
+
+## What changes when it is assigned
+
+Nothing on disk on either workstation: both packages are installed, and the service is enabled and
+running. `bluez` becomes explicitly the mesh's.
+
+## Leaves as found
+
+- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
+- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
+ That line is the `i3` module's to keep or drop.
+- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go
new file mode 100644
index 0000000..3022882
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth.go
@@ -0,0 +1,281 @@
+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
" lines.
+func listed(out string) []string {
+ seen := map[string]bool{}
+ addrs := []string{}
+ for _, l := range strings.Split(out, "\n") {
+ f := strings.Fields(strings.TrimSpace(l))
+ if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
+ seen[f[1]] = true
+ addrs = append(addrs, f[1])
+ }
+ }
+ return addrs
+}
+
+// Devices answers the devices bluez knows, with each one's state.
+func Devices(which string) (map[string]any, error) {
+ if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
+ return nil, err
+ }
+ args := []string{"devices"}
+ if which != "all" {
+ args = append(args, strings.ToUpper(which[:1])+which[1:])
+ }
+ out, err := bt(CallTimeout, args...)
+ if err != nil {
+ return nil, err
+ }
+ devices := []Device{}
+ for _, a := range listed(out) {
+ d, err := deviceInfo(a)
+ if err != nil {
+ return nil, err
+ }
+ devices = append(devices, d)
+ }
+ sort.SliceStable(devices, func(i, k int) bool {
+ if devices[i].Connected != devices[k].Connected {
+ return devices[i].Connected
+ }
+ return devices[i].Name < devices[k].Name
+ })
+ return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
+}
+
+// Scan discovers for a while and answers the devices found that are not paired.
+func Scan(seconds int) (map[string]any, error) {
+ // bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
+ limit := time.Duration(seconds+4) * time.Second
+ if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
+ return nil, err
+ }
+ out, err := bt(CallTimeout, "devices")
+ if err != nil {
+ return nil, err
+ }
+ found := []Device{}
+ for _, a := range listed(out) {
+ // A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
+ d, err := deviceInfo(a)
+ if err != nil {
+ continue
+ }
+ if !d.Paired {
+ found = append(found, d)
+ }
+ }
+ sort.SliceStable(found, func(i, k int) bool {
+ ri, rk := -1000, -1000
+ if found[i].RSSI != nil {
+ ri = *found[i].RSSI
+ }
+ if found[k].RSSI != nil {
+ rk = *found[k].RSSI
+ }
+ return ri > rk
+ })
+ return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
+}
+
+// Act runs one act on a device and answers the device's state afterwards.
+func Act(verb, address string) (map[string]any, error) {
+ limit := CallTimeout
+ if verb == "connect" {
+ // A connect waits for the device; bluetoothctl's own timeout ends it first.
+ if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
+ return nil, err
+ }
+ } else if _, err := bt(limit, verb, address); err != nil {
+ return nil, err
+ }
+ if verb == "remove" {
+ return map[string]any{"act": verb, "address": address, "removed": true}, nil
+ }
+ d, err := deviceInfo(address)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"act": verb, "device": d}, nil
+}
+
+// Pair pairs a device with an agent that confirms nothing, then trusts it.
+func Pair(address string) (map[string]any, error) {
+ if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
+ return nil, err
+ }
+ return Act("trust", address)
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go
new file mode 100644
index 0000000..117c5e5
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/bluetooth_test.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
+ m := readManifest(t)
+ holdsTheBundle(t, m, "bluetooth")
+ if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
+ t.Errorf("packages %s: the applet and the TUI are the operator's", got)
+ }
+ s := m.services()["bluetooth.service"]
+ if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
+ t.Errorf("%v", s)
+ }
+ if len(m.Resources) != 3 {
+ t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
+ }
+}
+
+const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
+
+func headphones(connected bool) string {
+ c := "no"
+ extra := ""
+ if connected {
+ c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
+ }
+ return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
+}
+
+func TestControllerReadsShow(t *testing.T) {
+ using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
+ c, err := Controller()
+ if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
+ t.Fatalf("%+v %v", c, err)
+ }
+ using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
+ if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch line {
+ case "bluetoothctl devices Paired":
+ return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
+ case "bluetoothctl info 80:99:E7:C2:29:DA":
+ return ok(headphones(true))
+ }
+ return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
+ })
+ got, err := Devices("paired")
+ devices := got["devices"].([]Device)
+ if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl devices Paired" {
+ t.Errorf("%v", f.lines())
+ }
+ if _, err := Devices("nearby"); err == nil {
+ t.Error("an unknown which")
+ }
+}
+
+func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
+ using(t, func(line string, c Cmd) Result {
+ return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
+ })
+ if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
+ t.Fatalf("%v", err)
+ }
+ using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
+ if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
+ t.Fatalf("%v", err)
+ }
+}
+
+func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, "connect") {
+ return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
+ }
+ return ok(headphones(true))
+ })
+ got, err := Act("connect", "80:99:E7:C2:29:DA")
+ if err != nil || !got["device"].(Device).Connected {
+ t.Fatalf("%v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
+ t.Errorf("%v", f.lines())
+ }
+}
+
+func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.HasPrefix(line, "sudo") {
+ return ok("Changing power off succeeded\n" + show)
+ }
+ return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
+ })
+ if _, err := Power(false); err != nil {
+ t.Fatal(err)
+ }
+ if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
+ t.Errorf("%v", l)
+ }
+}
+
+func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ switch {
+ case strings.Contains(line, "scan on"):
+ return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
+ case line == "bluetoothctl devices":
+ return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
+ case strings.HasSuffix(line, "EE:01"):
+ return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
+ case strings.HasSuffix(line, "EE:02"):
+ return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
+ case strings.HasSuffix(line, "EE:03"):
+ return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
+ }
+ return ok(headphones(false))
+ })
+ got, err := Scan(8)
+ found := got["found"].([]Device)
+ if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
+ t.Fatalf("%+v %v", got, err)
+ }
+ if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
+ t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
+ }
+}
+
+func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
+ f := using(t, func(line string, c Cmd) Result {
+ if strings.Contains(line, " pair ") {
+ return ok("Pairing successful\n")
+ }
+ return ok(headphones(false))
+ })
+ if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
+ t.Fatal(err)
+ }
+ if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
+ t.Errorf("%v", l)
+ }
+}
+
+func TestAnAddressIsSixHexPairs(t *testing.T) {
+ for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
+ if _, err := addressOf(map[string]any{"address": bad}); err == nil {
+ t.Errorf("%q accepted", bad)
+ }
+ }
+ if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
+ t.Errorf("%s %v", a, err)
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit.go b/modules/bluetooth/cmd/bluetooth-tools/kit.go
new file mode 100644
index 0000000..adc5aac
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/kit.go
@@ -0,0 +1,352 @@
+package main
+
+// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
+// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
+// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
+// than shared; a change to one copy is made to all eight.
+//
+// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
+// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
+// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
+// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
+// started when it takes longer;
+// - each stream is kept to 256 KiB, and the answer says when it was cut;
+// - a failure is an error with what went wrong in it, never an empty answer.
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Bounds every command is held to.
+const (
+ CallTimeout = 20 * time.Second
+ MostOutput = 256 << 10
+)
+
+// Cmd is one command a tool runs.
+type Cmd struct {
+ Name string
+ Args []string
+ // Stdin is written to the command's standard input when not empty.
+ Stdin string
+ // Env is added to this process's own environment.
+ Env []string
+ // Root says the command needs root: it is run through `sudo -n` when this process is not root.
+ Root bool
+ // Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
+ Timeout time.Duration
+ // Detached is for a program that forks a child which outlives it, as xclip does to keep the
+ // selection: its streams go to files, because a pipe the child inherits would hold the call open
+ // until the child exits.
+ Detached bool
+}
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr"`
+ Status int `json:"status"`
+ // Error is why it did not run to an answer: "not-found" when the program is not there,
+ // "timeout" when it was ended for taking too long, else the spawn error.
+ Error string `json:"error,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Runner runs a command. Tests replace it; nothing else does.
+type Runner func(Cmd) Result
+
+var (
+ run Runner = execRun
+ euid = os.Geteuid
+)
+
+// argv is the command as it is run: through sudo without a prompt when it needs root and this
+// process is not root.
+func argv(c Cmd) (string, []string) {
+ if c.Root && euid() != 0 {
+ return "sudo", append([]string{"-n", c.Name}, c.Args...)
+ }
+ return c.Name, c.Args
+}
+
+// bounded keeps the first MostOutput bytes written to it and notes that more came.
+type bounded struct {
+ b bytes.Buffer
+ cut bool
+}
+
+func (w *bounded) Write(p []byte) (int, error) {
+ room := MostOutput - w.b.Len()
+ if room <= 0 {
+ w.cut = w.cut || len(p) > 0
+ return len(p), nil
+ }
+ if len(p) > room {
+ w.b.Write(p[:room])
+ w.cut = true
+ return len(p), nil
+ }
+ return w.b.Write(p)
+}
+
+func execRun(c Cmd) Result {
+ timeout := c.Timeout
+ if timeout <= 0 {
+ timeout = CallTimeout
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ name, args := argv(c)
+ cmd := exec.CommandContext(ctx, name, args...)
+ cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
+ if !c.Detached {
+ // Its own process group, so that ending it on a timeout ends what it started too.
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ cmd.Cancel = func() error {
+ if cmd.Process != nil {
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ }
+ return nil
+ }
+ }
+ cmd.WaitDelay = 2 * time.Second
+ if c.Stdin != "" {
+ cmd.Stdin = strings.NewReader(c.Stdin)
+ }
+ var out, errs bounded
+ var outFile, errFile *os.File
+ if c.Detached {
+ var err error
+ if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(outFile.Name())
+ defer outFile.Close()
+ if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
+ return Result{Status: 127, Error: err.Error()}
+ }
+ defer os.Remove(errFile.Name())
+ defer errFile.Close()
+ cmd.Stdout, cmd.Stderr = outFile, errFile
+ } else {
+ cmd.Stdout, cmd.Stderr = &out, &errs
+ }
+ err := cmd.Run()
+ if c.Detached {
+ for _, f := range []struct {
+ file *os.File
+ into *bounded
+ }{{outFile, &out}, {errFile, &errs}} {
+ if _, e := f.file.Seek(0, io.SeekStart); e == nil {
+ _, _ = io.Copy(f.into, f.file)
+ }
+ }
+ }
+ r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
+ var exit *exec.ExitError
+ switch {
+ case err == nil:
+ case ctx.Err() == context.DeadlineExceeded:
+ r.Status, r.Error = 124, "timeout"
+ case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
+ r.Status, r.Error = 127, "not-found"
+ case errors.As(err, &exit):
+ r.Status = exit.ExitCode()
+ default:
+ r.Status, r.Error = 127, err.Error()
+ }
+ return r
+}
+
+// call runs a command and answers its result, or an error naming what went wrong.
+func call(c Cmd) (Result, error) {
+ r := run(c)
+ if r.Status == 0 && r.Error == "" {
+ return r, nil
+ }
+ return r, failure(c, r)
+}
+
+// failure names how a command failed: not installed, refused escalation, too slow, or its exit
+// status with the end of what it said.
+func failure(c Cmd, r Result) error {
+ program, _ := argv(c)
+ switch {
+ case r.Error == "not-found" && program == "sudo":
+ return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
+ case r.Error == "not-found":
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case r.Error == "timeout":
+ limit := c.Timeout
+ if limit <= 0 {
+ limit = CallTimeout
+ }
+ return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
+ case r.Error != "":
+ return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
+ case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
+ if hint, ok := providedBy[c.Name]; ok {
+ return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
+ }
+ return fmt.Errorf("%s is not installed on this machine", c.Name)
+ case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
+ return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
+ c.Name, firstLine(r.Stderr))
+ }
+ said := tail(strings.TrimSpace(r.Stderr), 2000)
+ if said == "" {
+ said = tail(strings.TrimSpace(r.Stdout), 2000)
+ }
+ if said == "" {
+ said = "and said nothing"
+ }
+ return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
+}
+
+func firstLine(s string) string {
+ s = strings.TrimSpace(s)
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func tail(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return "…" + s[len(s)-n:]
+}
+
+// lines are a command's output lines, blank ones dropped.
+func lines(s string) []string {
+ out := []string{}
+ for _, l := range strings.Split(s, "\n") {
+ if strings.TrimSpace(l) != "" {
+ out = append(out, strings.TrimRight(l, "\r"))
+ }
+ }
+ return out
+}
+
+// Arguments, read the way a tool's JSON arguments arrive.
+
+func text(args map[string]any, key string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return "", fmt.Errorf("%s must not be empty", key)
+ }
+ return s, nil
+}
+
+func optText(args map[string]any, key, def string) (string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s must be a string", key)
+ }
+ if strings.TrimSpace(s) == "" {
+ return def, nil
+ }
+ return s, nil
+}
+
+// optWhole reads a whole number, defaulted, refused below least and held to most.
+func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s must be a number", key)
+ }
+ }
+ if f != float64(int(f)) {
+ return 0, fmt.Errorf("%s must be a whole number", key)
+ }
+ n := int(f)
+ if n < least {
+ return 0, fmt.Errorf("%s must be at least %d", key, least)
+ }
+ if n > most {
+ n = most
+ }
+ return n, nil
+}
+
+func optFlag(args map[string]any, key string, def bool) (bool, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s must be true or false", key)
+ }
+ return b, nil
+}
+
+func optList(args map[string]any, key string) ([]string, error) {
+ v, ok := args[key]
+ if !ok || v == nil {
+ return nil, nil
+ }
+ items, ok := v.([]any)
+ if !ok {
+ return nil, fmt.Errorf("%s must be a list of strings", key)
+ }
+ out := make([]string, 0, len(items))
+ for _, it := range items {
+ s, ok := it.(string)
+ if !ok || strings.TrimSpace(s) == "" {
+ return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// oneOf refuses a value outside a closed set.
+func oneOf(key, value string, allowed ...string) error {
+ for _, a := range allowed {
+ if value == a {
+ return nil
+ }
+ }
+ return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
+}
+
+// plainName refuses a name that could be read as an option or carries a path or a space: package,
+// snap, application and printer names never do.
+func plainName(key, value string) error {
+ if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
+ return fmt.Errorf("%s %q is not a plain name", key, value)
+ }
+ return nil
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go
new file mode 100644
index 0000000..c5d3557
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/kit_test.go
@@ -0,0 +1,147 @@
+package main
+
+// Tests of kit.go, the same in each workstation module.
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+// fake records the commands asked and answers each from a function of the command line.
+type fake struct {
+ asked []Cmd
+ answer func(line string, c Cmd) Result
+}
+
+func (f *fake) runner() Runner {
+ return func(c Cmd) Result {
+ f.asked = append(f.asked, c)
+ name, args := argv(c)
+ line := strings.TrimSpace(name + " " + strings.Join(args, " "))
+ if f.answer == nil {
+ return Result{}
+ }
+ return f.answer(line, c)
+ }
+}
+
+func (f *fake) lines() []string {
+ out := []string{}
+ for _, c := range f.asked {
+ name, args := argv(c)
+ out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
+ }
+ return out
+}
+
+// using installs a fake runner and a non-root uid for one test.
+func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
+ t.Helper()
+ f := &fake{answer: answer}
+ wasRun, wasUID := run, euid
+ run, euid = f.runner(), func() int { return 1000 }
+ t.Cleanup(func() { run, euid = wasRun, wasUID })
+ return f
+}
+
+func ok(stdout string) Result { return Result{Stdout: stdout} }
+
+func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
+ t.Fatalf("not root: %s %v", name, args)
+ }
+ if name, _ := argv(Cmd{Name: "x"}); name != "x" {
+ t.Fatalf("a read is run as the account: %s", name)
+ }
+ euid = func() int { return 0 }
+ if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
+ t.Fatalf("as root no sudo: %s", name)
+ }
+}
+
+func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
+ was := euid
+ defer func() { euid = was }()
+ euid = func() int { return 1000 }
+ cases := []struct {
+ c Cmd
+ r Result
+ want string
+ }{
+ {Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
+ {Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
+ {Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
+ {Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
+ {Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
+ }
+ for _, k := range cases {
+ err := failure(k.c, k.r)
+ if err == nil || !strings.Contains(err.Error(), k.want) {
+ t.Errorf("%+v: %v, want %q", k.r, err, k.want)
+ }
+ }
+}
+
+func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
+ var w bounded
+ big := strings.Repeat("a", MostOutput+10)
+ n, _ := w.Write([]byte(big))
+ if n != len(big) || w.b.Len() != MostOutput || !w.cut {
+ t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
+ }
+}
+
+func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
+ r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
+ if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("%+v", r)
+ }
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
+ if r.Error != "timeout" {
+ t.Fatalf("a slow command: %+v", r)
+ }
+ r = execRun(Cmd{Name: "no-such-program-anywhere"})
+ if r.Error != "not-found" {
+ t.Fatalf("a missing program: %+v", r)
+ }
+ r = execRun(Cmd{Name: "cat", Stdin: "given"})
+ if r.Stdout != "given" {
+ t.Fatalf("stdin: %+v", r)
+ }
+ start := time.Now()
+ r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
+ if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
+ t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
+ }
+}
+
+func TestKitArgumentsAreReadStrictly(t *testing.T) {
+ args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
+ if _, err := text(args, "missing"); err == nil {
+ t.Error("a missing required string")
+ }
+ if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
+ t.Errorf("held to most: %d", n)
+ }
+ if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
+ t.Error("below least")
+ }
+ if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
+ t.Error("a fraction")
+ }
+ if l, _ := optList(args, "l"); len(l) != 2 {
+ t.Errorf("list: %v", l)
+ }
+ if b, _ := optFlag(args, "b", false); !b {
+ t.Error("flag")
+ }
+ if err := plainName("name", "--all"); err == nil {
+ t.Error("an option as a name")
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/main.go b/modules/bluetooth/cmd/bluetooth-tools/main.go
new file mode 100644
index 0000000..f063aaf
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/main.go
@@ -0,0 +1,135 @@
+// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
+// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
+// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
+// account, and speaks to bluez through bluetoothctl.
+package main
+
+import (
+ "fmt"
+ "os"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+)
+
+var providedBy = map[string]string{
+ "bluetoothctl": "the bluez-utils package, which this module installs",
+}
+
+func main() {
+ if err := stdio.Serve("", tools()); err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+}
+
+var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
+
+func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
+ return func(args map[string]any) (any, error) {
+ a, err := addressOf(args)
+ if err != nil {
+ return nil, err
+ }
+ return f(a)
+ }
+}
+
+func tools() []stdio.Tool {
+ return []stdio.Tool{
+ {
+ Name: "bluetooth_controller",
+ Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
+ Input: map[string]any{},
+ Run: func(map[string]any) (any, error) { return Controller() },
+ },
+ {
+ Name: "bluetooth_power",
+ Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
+ Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
+ Run: func(args map[string]any) (any, error) {
+ if _, given := args["on"]; !given {
+ c, err := Controller()
+ if err != nil {
+ return nil, err
+ }
+ return map[string]any{"powered": c.Powered}, nil
+ }
+ on, err := optFlag(args, "on", true)
+ if err != nil {
+ return nil, err
+ }
+ return Power(on)
+ },
+ },
+ {
+ Name: "bluetooth_devices",
+ Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
+ "name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
+ Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
+ Run: func(args map[string]any) (any, error) {
+ which, err := optText(args, "which", "all")
+ if err != nil {
+ return nil, err
+ }
+ return Devices(which)
+ },
+ },
+ {
+ Name: "bluetooth_scan",
+ Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
+ "paired, with their signal strength. (r)",
+ Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
+ Run: func(args map[string]any) (any, error) {
+ s, err := optWhole(args, "seconds", 8, 1, 15)
+ if err != nil {
+ return nil, err
+ }
+ return Scan(s)
+ },
+ },
+ {
+ Name: "bluetooth_connect",
+ Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
+ },
+ {
+ Name: "bluetooth_disconnect",
+ Description: "Disconnect a device. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
+ },
+ {
+ Name: "bluetooth_trust",
+ Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
+ Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
+ Run: func(args map[string]any) (any, error) {
+ a, err := addressOf(args)
+ if err != nil {
+ return nil, err
+ }
+ trusted, err := optFlag(args, "trusted", true)
+ if err != nil {
+ return nil, err
+ }
+ if trusted {
+ return Act("trust", a)
+ }
+ return Act("untrust", a)
+ },
+ },
+ {
+ Name: "bluetooth_pair",
+ Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
+ "confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Pair(a) }),
+ },
+ {
+ Name: "bluetooth_remove",
+ Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
+ Input: map[string]any{"address": addressArg},
+ Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
+ },
+ }
+}
diff --git a/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go
new file mode 100644
index 0000000..3e675b4
--- /dev/null
+++ b/modules/bluetooth/cmd/bluetooth-tools/manifest_kit_test.go
@@ -0,0 +1,107 @@
+package main
+
+// manifest_kit_test.go is the same file in each workstation module: it reads the module's
+// definition so the module's own tests can hold it to what it says.
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+type manifest struct {
+ Module string `json:"module"`
+ Capabilities []string `json:"capabilities"`
+ Claims []any `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m manifest
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(id string) map[string]any {
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ return nil
+}
+
+// packages are the packages the module installs, sorted.
+func (m manifest) packages() []string {
+ out := []string{}
+ for _, r := range m.Resources {
+ if r["type"] == "package" && r["absent"] != true {
+ out = append(out, r["package"].(string))
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// services are the units the module declares, by unit name.
+func (m manifest) services() map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range m.Resources {
+ if r["type"] == "service" {
+ out[r["unit"].(string)] = r
+ }
+ }
+ return out
+}
+
+// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
+// is listed and nothing else, each named _…, and the artifact builds this command.
+func holdsTheBundle(t *testing.T, m manifest, prefix string) {
+ t.Helper()
+ registered := []string{}
+ for _, tool := range tools() {
+ registered = append(registered, tool.Name)
+ if !strings.HasPrefix(tool.Name, prefix+"_") {
+ t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
+ }
+ if tool.Description == "" || tool.Run == nil || tool.Input == nil {
+ t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
+ }
+ }
+ if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
+ t.Errorf("registered %v, listed %v", registered, m.Tools)
+ }
+ if len(m.Build.Artifacts) != 1 {
+ t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
+ }
+ cwd, _ := os.Getwd()
+ binary := filepath.Base(cwd)
+ a := m.Build.Artifacts[0]
+ want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
+ for k, v := range want {
+ if a[k] != v {
+ t.Errorf("artifact %s = %v, want %v", k, a[k], v)
+ }
+ }
+ if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
+ t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
+ }
+ if m.Claims != nil || m.Seats != nil {
+ t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
+ }
+}
diff --git a/modules/bluetooth/go.mod b/modules/bluetooth/go.mod
new file mode 100644
index 0000000..790596e
--- /dev/null
+++ b/modules/bluetooth/go.mod
@@ -0,0 +1,5 @@
+module bluetooth
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.6
diff --git a/modules/bluetooth/go.sum b/modules/bluetooth/go.sum
new file mode 100644
index 0000000..0dd6061
--- /dev/null
+++ b/modules/bluetooth/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
+git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/bluetooth/module.json b/modules/bluetooth/module.json
new file mode 100644
index 0000000..dfaabf1
--- /dev/null
+++ b/modules/bluetooth/module.json
@@ -0,0 +1,53 @@
+{
+ "module": "bluetooth",
+ "version": "1",
+ "capabilities": [
+ "package-manager",
+ "service-manager"
+ ],
+ "tools": [
+ "bluetooth_controller",
+ "bluetooth_power",
+ "bluetooth_devices",
+ "bluetooth_scan",
+ "bluetooth_connect",
+ "bluetooth_disconnect",
+ "bluetooth_trust",
+ "bluetooth_pair",
+ "bluetooth_remove"
+ ],
+ "resources": [
+ {
+ "id": "stack",
+ "type": "package",
+ "package": "bluez"
+ },
+ {
+ "id": "utilities",
+ "type": "package",
+ "package": "bluez-utils"
+ },
+ {
+ "id": "daemon",
+ "type": "service",
+ "unit": "bluetooth.service",
+ "state": "running",
+ "boot": "enabled"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/bluetooth-tools",
+ "binary": "bluetooth-tools",
+ "loads": [
+ "bluetooth-tools"
+ ]
+ }
+ ]
+ }
+}