commit 2601611a46a43aaa8a383b3b863110e92604e7ec Author: Niko Marmeladkov Date: Sat May 30 10:25:53 2026 +0300 Initial commit: nimbt v0.1.0 Lightweight Bluetooth manager for Linux with: - D-Bus BlueZ client via nim-dbus - CLI binary (nbt) — list, scan, power, connect, etc. - Daemon (nimbt-daemon) — event loop + UNIX socket IPC - JSON, TOML, YAML config support - Nerd Font icons + plain/JSON output modes - Autoconnect engine for trusted devices - systemd user service unit - Arch Linux PKGBUILD diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55ee06b --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.o +nimbt +nimbt-daemon +nbt +nimbt_daemon +nimbt-daemon-d +nbt-d +nimbt_daemon-d +nbt-d +/D +*.exe +*.out diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 0000000..18b74e0 --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,46 @@ +# Maintainer: nimbt +pkgname=nimbt +pkgver=0.1.0 +pkgrel=1 +pkgdesc="Lightweight Bluetooth manager for Linux (daemon + CLI)" +arch=('x86_64' 'aarch64') +url="https://git.n1kt.ru/Niko/nim-bt" +license=('MIT') +depends=('dbus' 'glibc') +makedepends=('nim' 'nimble' 'git') +source=("$pkgname-$pkgver.tar.gz::https://git.n1kt.ru/Niko/nim-bt/archive/v$pkgver.tar.gz") +sha256sums=('SKIP') + +prepare() { + cd "$srcdir/$pkgname" + nimble install -y --depsOnly --nimbleDir:"$srcdir/nimble" 2>/dev/null || true +} + +build() { + cd "$srcdir/$pkgname" + export NIMBLE_DIR="$srcdir/nimble" + nim c \ + --path:"$NIMBLE_DIR/pkgs2" \ + --gc:orc \ + -d:release \ + --opt:speed \ + --hints:off \ + -o:build/nbt src/nbt.nim + nim c \ + --path:"$NIMBLE_DIR/pkgs2" \ + --gc:orc \ + -d:release \ + --opt:speed \ + --hints:off \ + -o:build/nimbt-daemon src/nimbt_daemon.nim +} + +package() { + cd "$srcdir/$pkgname" + install -Dm755 build/nbt "$pkgdir/usr/bin/nbt" + install -Dm755 build/nimbt-daemon "$pkgdir/usr/bin/nimbt-daemon" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + install -Dm644 PKGBUILD "$pkgdir/usr/share/doc/$pkgname/PKGBUILD" + + install -Dm644 -t "$pkgdir/usr/lib/systemd/user" nimbt-daemon.service +} diff --git a/config.nims b/config.nims new file mode 100644 index 0000000..9be1462 --- /dev/null +++ b/config.nims @@ -0,0 +1,3 @@ +--gc:orc +--define:release +--hints:off diff --git a/nimbt-daemon.service b/nimbt-daemon.service new file mode 100644 index 0000000..5bc7fc3 --- /dev/null +++ b/nimbt-daemon.service @@ -0,0 +1,12 @@ +[Unit] +Description=nim-bt Bluetooth daemon +After=bluetooth.service +BindsTo=bluetooth.service + +[Service] +ExecStart=%h/nim-bt/src/nimbt_daemon +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/nimbt.nimble b/nimbt.nimble new file mode 100644 index 0000000..3d4ac69 --- /dev/null +++ b/nimbt.nimble @@ -0,0 +1,12 @@ +version = "0.1.0" +author = "nimbt" +description = "Lightweight Bluetooth manager for Linux" +license = "MIT" + +srcDir = "src" +bin = @["nbt", "nimbt_daemon"] + +requires "nim >= 2.0.0" +requires "dbus" +requires "parsetoml" +requires "yaml" diff --git a/src/bt_autoconnect.nim b/src/bt_autoconnect.nim new file mode 100644 index 0000000..dc31504 --- /dev/null +++ b/src/bt_autoconnect.nim @@ -0,0 +1,52 @@ +import tables, times +import bt_types, bt_dbus + +type + AutoConnectState* = ref object + enabled*: bool + trustedOnly*: bool + retryInterval*: int + deviceAuto*: Table[string, bool] + retryTimers*: Table[string, float] + config*: Config + + AutoConnectEvent* = enum + aceDeviceConnected + aceDeviceDisconnected + aceDeviceRemoved + +proc newAutoConnectState*(config: Config): AutoConnectState = + new(result) + result.enabled = config.autoconnect + result.trustedOnly = config.autoconnectTrustedOnly + result.retryInterval = config.autoconnectRetryInterval + result.deviceAuto = initTable[string, bool]() + result.retryTimers = initTable[string, float]() + result.config = config + +proc shouldAutoConnect*(state: AutoConnectState; dev: Device): bool = + if not state.enabled: return false + if dev.blocked: return false + if state.trustedOnly and not dev.trusted: return false + if state.deviceAuto.hasKey(dev.address) and not state.deviceAuto[dev.address]: return false + if dev.connected: return false + true + +proc onDeviceDisconnected*(state: AutoConnectState; dev: Device) = + if state.shouldAutoConnect(dev): + state.retryTimers[dev.address] = epochTime() + state.retryInterval.float + +proc tick*(state: AutoConnectState; bz: BluezConn) = + let now = epochTime() + var toReconnect: seq[string] + for addr, tm in state.retryTimers: + if now >= tm: + toReconnect.add(addr) + for addr in toReconnect: + state.retryTimers.del(addr) + try: + let dev = bz.findDevice(addr) + if dev.path.len > 0 and not dev.connected: + bz.connectDevice(addr) + except: + discard diff --git a/src/bt_config.nim b/src/bt_config.nim new file mode 100644 index 0000000..7b8d52b --- /dev/null +++ b/src/bt_config.nim @@ -0,0 +1,132 @@ +import os, strutils, tables, json, streams, parsetoml +import yaml/dom, yaml/loading +import bt_types + +type ConfigFormat = enum + cfJson, cfToml, cfYaml + +proc detectConfig(path: string): ConfigFormat = + let ext = path.splitFile.ext.toLowerAscii + case ext + of ".json": cfJson + of ".toml": cfToml + of ".yaml", ".yml": cfYaml + else: cfJson + +proc parseJsonConfig(path: string): Config = + let node = json.parseFile(path) + result = Config( + autoconnect: false, + autoconnectTrustedOnly: true, + autoconnectRetryInterval: 10, + icons: initTable[DeviceType, string](), + aliases: initTable[string, string]() + ) + if node.kind != JObject: return + if node.hasKey("autoconnect"): + result.autoconnect = node["autoconnect"].getBool + if node.hasKey("autoconnect_trusted_only"): + result.autoconnectTrustedOnly = node["autoconnect_trusted_only"].getBool + if node.hasKey("autoconnect_retry_interval"): + result.autoconnectRetryInterval = node["autoconnect_retry_interval"].getInt + if node.hasKey("icons") and node["icons"].kind == JObject: + for k, v in node["icons"].fields: + let dt = parseEnum[DeviceType](k, dtUnknown) + if dt != dtUnknown: + result.icons[dt] = v.getStr("") + if node.hasKey("aliases") and node["aliases"].kind == JObject: + for k, v in node["aliases"].fields: + result.aliases[k] = v.getStr("") + if node.hasKey("default_adapter"): + result.defaultAdapter = node["default_adapter"].getStr("") + +proc parseTomlConfig(path: string): Config = + result = Config( + autoconnect: false, + autoconnectTrustedOnly: true, + autoconnectRetryInterval: 10, + icons: initTable[DeviceType, string](), + aliases: initTable[string, string]() + ) + let content = readFile(path) + let toml = parsetoml.parseString(content) + let root = toml + if root.hasKey("autoconnect"): + result.autoconnect = root["autoconnect"].getBool + if root.hasKey("autoconnect_trusted_only"): + result.autoconnectTrustedOnly = root["autoconnect_trusted_only"].getBool + if root.hasKey("autoconnect_retry_interval"): + result.autoconnectRetryInterval = root["autoconnect_retry_interval"].getInt + if root.hasKey("icons"): + let iconsVal = root["icons"] + if iconsVal.kind == parsetoml.TomlValueKind.Table: + for k, v in iconsVal.tableVal: + let dt = parseEnum[DeviceType](k, dtUnknown) + if dt != dtUnknown: + result.icons[dt] = v.getStr("") + if root.hasKey("aliases"): + let aliasVal = root["aliases"] + if aliasVal.kind == parsetoml.TomlValueKind.Table: + for k, v in aliasVal.tableVal: + result.aliases[k] = v.getStr("") + if root.hasKey("default_adapter"): + result.defaultAdapter = root["default_adapter"].getStr("") + +proc parseYamlConfig(path: string): Config = + result = Config( + autoconnect: false, + autoconnectTrustedOnly: true, + autoconnectRetryInterval: 10, + icons: initTable[DeviceType, string](), + aliases: initTable[string, string]() + ) + var yamlNode: YamlNode + load(newFileStream(path), yamlNode) + if yamlNode.isNil or yamlNode.kind != yMapping: return + for k, v in yamlNode.fields.pairs: + if k.content == "autoconnect" and v.kind == yScalar: + result.autoconnect = v.content == "true" + elif k.content == "autoconnect_trusted_only" and v.kind == yScalar: + result.autoconnectTrustedOnly = v.content == "true" + elif k.content == "autoconnect_retry_interval" and v.kind == yScalar: + try: + result.autoconnectRetryInterval = parseInt(v.content) + except: discard + elif k.content == "icons" and v.kind == yMapping: + for ik, iv in v.fields.pairs: + let dt = parseEnum[DeviceType](ik.content, dtUnknown) + if dt != dtUnknown and iv.kind == yScalar: + result.icons[dt] = iv.content + elif k.content == "aliases" and v.kind == yMapping: + for ik, iv in v.fields.pairs: + if iv.kind == yScalar: + result.aliases[ik.content] = iv.content + elif k.content == "default_adapter" and v.kind == yScalar: + result.defaultAdapter = v.content + +proc loadConfig*(path: string = ""): Config = + var configDir: string + if path.len > 0: + configDir = path + else: + let xdgConfig = getEnv("XDG_CONFIG_HOME") + if xdgConfig.len > 0: + configDir = xdgConfig / "nimbt" + else: + configDir = getHomeDir() / ".config" / "nimbt" + createDir(configDir) + for ext in [".json", ".toml", ".yaml", ".yml"]: + let cfgPath = configDir / "config" & ext + if fileExists(cfgPath): + let fmt = detectConfig(cfgPath) + case fmt + of cfJson: return parseJsonConfig(cfgPath) + of cfToml: return parseTomlConfig(cfgPath) + of cfYaml: return parseYamlConfig(cfgPath) + result = Config( + autoconnect: false, + autoconnectTrustedOnly: true, + autoconnectRetryInterval: 10, + icons: initTable[DeviceType, string](), + aliases: initTable[string, string]() + ) diff --git a/src/bt_dbus.nim b/src/bt_dbus.nim new file mode 100644 index 0000000..cbea6c1 --- /dev/null +++ b/src/bt_dbus.nim @@ -0,0 +1,214 @@ +import dbus, dbus/lowlevel, tables, strutils, sequtils +import bt_types + +const + BluezDest* = "org.bluez" + BluezPath* = "/" + PropIface* = "org.freedesktop.DBus.Properties" + ObjMgrIface* = "org.freedesktop.DBus.ObjectManager" + AdapterIface* = "org.bluez.Adapter1" + DeviceIface* = "org.bluez.Device1" + BatteryIface* = "org.bluez.BatteryProvider1" + +type + BluezConn* = ref object + bus*: Bus + adapters*: seq[Adapter] + devices*: seq[Device] + +proc newBluezConn*(): BluezConn = + new(result) + result.bus = getBus(DBUS_BUS_SYSTEM) + result.adapters = @[] + result.devices = @[] + +proc dbusCall*(bz: BluezConn; path, iface, methodName: string; args: openarray[DbusValue] = []): Reply = + var msg = makeCall(BluezDest, path.ObjectPath, iface, methodName) + for a in args: + msg.append(a) + let pending = sendMessageWithReply(bz.bus, msg) + result = waitForReply(pending) + result.raiseIfError() + +proc dbusGet*(bz: BluezConn; path, iface, prop: string): DbusValue = + let reply = bz.dbusCall(path, PropIface, "Get", @[iface.asDbusValue, prop.asDbusValue]) + var iter = reply.iterate() + var val = iter.unpackCurrent(DbusValue) + if val != nil and val.kind == dtVariant: + val = val.variantValue + result = val + +proc dbusSet*(bz: BluezConn; path, iface, prop: string; val: DbusValue) = + let variantVal = asDbusValue(newVariant(val)) + discard bz.dbusCall(path, PropIface, "Set", @[iface.asDbusValue, prop.asDbusValue, variantVal]) + +proc dbusCallMethod*(bz: BluezConn; path, iface, methodName: string; args: openarray[DbusValue] = []): Reply = + result = bz.dbusCall(path, iface, methodName, args) + +proc dbusGetAll*(bz: BluezConn; path, iface: string): Table[string, DbusValue] = + let reply = bz.dbusCall(path, PropIface, "GetAll", @[iface.asDbusValue]) + var iter = reply.iterate() + let val = iter.unpackCurrent(DbusValue) + if val.kind == dtArray: + for entry in val.arrayValue: + if entry.kind == dtDictEntry: + let key = entry.dictKey.stringValue + var value = entry.dictValue + if value.kind == dtVariant: + value = value.variantValue + result[key] = value + +proc readDbusValueAsString(val: DbusValue): string = + if val == nil: return "" + case val.kind + of dtString: result = val.stringValue + of dtUint32: result = $val.uint32Value + of dtInt32: result = $val.int32Value + of dtUint16: result = $val.uint16Value + of dtInt16: result = $val.int16Value + of dtUint64: result = $val.uint64Value + of dtInt64: result = $val.int64Value + of dtBool: result = $val.boolValue + of dtByte: result = $val.byteValue + of dtArray: result = "[" & val.arrayValue.map(readDbusValueAsString).join(", ") & "]" + else: result = $val + +proc readDbusVariant(val: DbusValue): DbusValue = + if val.kind == dtVariant: + result = val.variantValue + else: + result = val + +proc propsToDevice(path: string; props: Table[string, DbusValue]): Device = + result.path = path + result.address = props.getOrDefault("Address", DbusValue(kind: dtString, stringValue: "")).readDbusValueAsString + result.name = props.getOrDefault("Name", DbusValue(kind: dtString, stringValue: "")).readDbusValueAsString + result.alias = props.getOrDefault("Alias", DbusValue(kind: dtString, stringValue: "")).readDbusValueAsString + result.trusted = props.getOrDefault("Trusted", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.blocked = props.getOrDefault("Blocked", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.paired = props.getOrDefault("Paired", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.connected = props.getOrDefault("Connected", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.uuids = @[] + let uuidsVal = props.getOrDefault("UUIDs") + if uuidsVal != nil and uuidsVal.kind == dtArray: + for v in uuidsVal.arrayValue: + result.uuids.add(v.stringValue) + let appearVal = props.getOrDefault("Appearance") + if appearVal != nil: + result.appearance = appearVal.readDbusValueAsString.parseInt + result.status = if result.connected: dsConnected elif result.paired: dsPaired else: dsDisconnected + +proc propsToAdapter(path: string; props: Table[string, DbusValue]): Adapter = + result.path = path + result.address = props.getOrDefault("Address", DbusValue(kind: dtString, stringValue: "")).readDbusValueAsString + result.name = props.getOrDefault("Name", DbusValue(kind: dtString, stringValue: "")).readDbusValueAsString + result.powered = props.getOrDefault("Powered", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.discovering = props.getOrDefault("Discovering", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.discoverable = props.getOrDefault("Discoverable", DbusValue(kind: dtBool, boolValue: false)).boolValue + result.pairable = props.getOrDefault("Pairable", DbusValue(kind: dtBool, boolValue: false)).boolValue + +proc refresh*(bz: BluezConn) = + let reply = bz.dbusCall(BluezPath, ObjMgrIface, "GetManagedObjects") + var iter = reply.iterate() + let root = iter.unpackCurrent(DbusValue) + bz.adapters = @[] + bz.devices = @[] + if root.kind == dtArray: + for entry in root.arrayValue: + if entry.kind == dtDictEntry: + let objPath = entry.dictKey.objectPathValue.string + let ifacesVal = entry.dictValue + if ifacesVal.kind == dtArray: + var adapterProps: Table[string, DbusValue] + var deviceProps: Table[string, DbusValue] + for ifaceEntry in ifacesVal.arrayValue: + if ifaceEntry.kind == dtDictEntry: + let ifaceName = ifaceEntry.dictKey.stringValue + let propsDict = ifaceEntry.dictValue + var props: Table[string, DbusValue] + if propsDict.kind == dtArray: + for propEntry in propsDict.arrayValue: + if propEntry.kind == dtDictEntry: + let key = propEntry.dictKey.stringValue + var val = propEntry.dictValue + if val.kind == dtVariant: + val = val.variantValue + props[key] = val + if ifaceName == AdapterIface: + adapterProps = props + elif ifaceName == DeviceIface: + deviceProps = props + if adapterProps.len > 0: + bz.adapters.add(propsToAdapter(objPath, adapterProps)) + if deviceProps.len > 0: + bz.devices.add(propsToDevice(objPath, deviceProps)) + +proc getAdapter*(bz: BluezConn; adapterAddr: string = ""): Adapter = + if adapterAddr.len > 0: + for a in bz.adapters: + if a.address == adapterAddr: return a + if bz.adapters.len > 0: + return bz.adapters[0] + +proc scan*(bz: BluezConn; onoff: bool) = + let adapter = bz.getAdapter() + if adapter.path.len == 0: return + if onoff: + discard bz.dbusCallMethod(adapter.path, AdapterIface, "StartDiscovery") + else: + discard bz.dbusCallMethod(adapter.path, AdapterIface, "StopDiscovery") + bz.refresh() + +proc setPowered*(bz: BluezConn; onoff: bool) = + let adapter = bz.getAdapter() + if adapter.path.len == 0: return + bz.dbusSet(adapter.path, AdapterIface, "Powered", asDbusValue(onoff)) + bz.refresh() + +proc findDevice*(bz: BluezConn; query: string): Device = + for d in bz.devices: + if d.address == query or d.name == query or d.alias == query: + return d + +proc connectDevice*(bz: BluezConn; devAddr: string) = + let dev = bz.findDevice(devAddr) + if dev.path.len == 0: raise newException(ValueError, "device not found: " & devAddr) + discard bz.dbusCallMethod(dev.path, DeviceIface, "Connect") + bz.refresh() + +proc disconnectDevice*(bz: BluezConn; devAddr: string) = + let dev = bz.findDevice(devAddr) + if dev.path.len == 0: raise newException(ValueError, "device not found: " & devAddr) + discard bz.dbusCallMethod(dev.path, DeviceIface, "Disconnect") + bz.refresh() + +proc pairDevice*(bz: BluezConn; devAddr: string) = + let dev = bz.findDevice(devAddr) + if dev.path.len == 0: raise newException(ValueError, "device not found: " & devAddr) + discard bz.dbusCallMethod(dev.path, DeviceIface, "Pair") + bz.refresh() + +proc trustDevice*(bz: BluezConn; devAddr: string; trust: bool = true) = + let dev = bz.findDevice(devAddr) + if dev.path.len == 0: raise newException(ValueError, "device not found: " & devAddr) + bz.dbusSet(dev.path, DeviceIface, "Trusted", asDbusValue(trust)) + bz.refresh() + +proc removeDevice*(bz: BluezConn; devAddr: string) = + let adapter = bz.getAdapter() + let dev = bz.findDevice(devAddr) + if adapter.path.len == 0 or dev.path.len == 0: + raise newException(ValueError, "device not found: " & devAddr) + discard bz.dbusCallMethod(adapter.path, AdapterIface, "RemoveDevice", @[dev.path.ObjectPath.asDbusValue]) + bz.refresh() + +proc subscribeSignals*(bz: BluezConn) = + let rule1 = "interface='org.freedesktop.DBus.Properties',type='signal',path_namespace='/org/bluez'" + let rule2 = "interface='org.freedesktop.DBus.ObjectManager',type='signal',path='/'" + var err: DBusError + dbus_error_init(addr err) + dbus_bus_add_match(bz.bus.conn, rule1.cstring, addr err) + dbus_bus_add_match(bz.bus.conn, rule2.cstring, addr err) + +proc dispatch*(bz: BluezConn; timeoutMs: int = 0): bool = + result = dbus_connection_read_write_dispatch(bz.bus.conn, timeoutMs.cint) != 0 diff --git a/src/bt_device.nim b/src/bt_device.nim new file mode 100644 index 0000000..fe41f89 --- /dev/null +++ b/src/bt_device.nim @@ -0,0 +1,116 @@ +import strutils, sequtils +import bt_types + +const + AppearanceUnknown* = 0x0000 + AppearancePhone* = 0x0040 + AppearanceComputer* = 0x0080 + AppearanceWatch* = 0x00C0 + AppearanceSpeaker* = 0x0141 + AppearanceHeadset* = 0x0142 + AppearanceHeadphone* = 0x0143 + AppearanceMic* = 0x0144 + AppearanceEarbud* = 0x0145 + AppearanceGamepad* = 0x0208 + AppearanceKeyboard* = 0x0540 + AppearanceMouse* = 0x0580 + AppearanceJoystick* = 0x0600 + AppearancePrinter* = 0x0700 + AppearanceScanner* = 0x0800 + AppearanceTherm* = 0x1200 + AppearanceHeartRate* = 0x1800 + +proc classifyByAppearance*(appearance: int): DeviceType = + case appearance + of 0x0040: dtPhone + of 0x0080: dtComputer + of 0x00C0, 0x00C1, 0x0100: dtWearable + of 0x0141, 0x0142, 0x0143, 0x0144, 0x0145: dtAudio + of 0x0208, 0x0600: dtGamepad + of 0x0540: dtKeyboard + of 0x0580: dtMouse + of 0x0700: dtPrinter + of 0x0800: dtScanner + of 0x0140: dtDisplay + of 0x1200, 0x1800: dtHealth + else: dtUnknown + +proc classifyByCoD*(cod: int): DeviceType = + let major = (cod shr 8) and 0x1F + case major + of 0x01: dtComputer + of 0x02: dtPhone + of 0x03: dtNetwork + of 0x04: dtAudio + of 0x05: + let minor = (cod shr 2) and 0x3F + case minor + of 0x10: dtKeyboard + of 0x20: dtMouse + of 0x30, 0x40: dtGamepad + else: dtUnknown + of 0x06: + let minor = (cod shr 2) and 0x3F + case minor + of 0x10: dtPrinter + of 0x20: dtScanner + of 0x40: dtCamera + of 0x80: dtDisplay + else: dtUnknown + of 0x07: dtWearable + of 0x08: dtToy + of 0x09: dtHealth + else: dtUnknown + +const + uuidA2DP = "0000110d-0000-1000-8000-00805f9b34fb" + uuidHSP = "00001108-0000-1000-8000-00805f9b34fb" + uuidHFP = "0000111e-0000-1000-8000-00805f9b34fb" + uuidHID = "00001124-0000-1000-8000-00805f9b34fb" + +proc classifyByUUIDs*(uuids: seq[string]): DeviceType = + let uuidsLower = uuids.mapIt(it.toLowerAscii) + for u in uuidsLower: + case u + of uuidA2DP, uuidHSP, uuidHFP: return dtAudio + of uuidHID: discard + else: discard + dtUnknown + +proc classifyByName*(name: string): DeviceType = + let lower = name.toLowerAscii + if lower.contains("headphone") or lower.contains("headset") or + lower.contains("earphone") or lower.contains("earbud") or + lower.contains("speaker") or lower.contains("audio") or + lower.contains("headset"): + return dtAudio + elif lower.contains("keyboard") or lower.contains("keypad"): + return dtKeyboard + elif lower.contains("mouse") or lower.contains("trackpad") or + lower.contains("touchpad"): + return dtMouse + elif lower.contains("gamepad") or lower.contains("controller") or + lower.contains("joystick"): + return dtGamepad + elif lower.contains("phone") or lower.contains("mobile") or + lower.contains("smartphone"): + return dtPhone + elif lower.contains("printer"): + return dtPrinter + elif lower.contains("camera"): + return dtCamera + elif lower.contains("watch") or lower.contains("band") or + lower.contains("fitness") or lower.contains("wearable"): + return dtWearable + elif lower.contains("laptop") or lower.contains("computer") or + lower.contains("desktop") or lower.contains("pc"): + return dtComputer + dtUnknown + +proc classifyDevice*(device: var Device) = + if device.appearance > 0: + let t = classifyByAppearance(device.appearance) + if t != dtUnknown: + device.deviceType = t + return + device.deviceType = classifyByName(device.name) diff --git a/src/bt_icons.nim b/src/bt_icons.nim new file mode 100644 index 0000000..92f72e5 --- /dev/null +++ b/src/bt_icons.nim @@ -0,0 +1,108 @@ +import strutils, strformat, json +import bt_types, bt_device + +const + IconBluetooth* = "" + IconAudio* = "" + IconKeyboard* = "" + IconMouse* = "" + IconGamepad* = "" + IconPhone* = "" + IconPrinter* = "" + IconCamera* = "" + IconWearable* = "" + IconComputer* = "" + IconNetwork* = "" + IconScanner* = "" + IconDisplay* = "" + IconToy* = "" + IconHealth* = "" + IconUnknown* = "" + +proc defaultIcon*(dt: DeviceType): string = + case dt + of dtAudio: IconAudio + of dtKeyboard: IconKeyboard + of dtMouse: IconMouse + of dtGamepad: IconGamepad + of dtPhone: IconPhone + of dtPrinter: IconPrinter + of dtCamera: IconCamera + of dtWearable: IconWearable + of dtComputer: IconComputer + of dtNetwork: IconNetwork + of dtScanner: IconScanner + of dtDisplay: IconDisplay + of dtToy: IconToy + of dtHealth: IconHealth + of dtUnknown: IconUnknown + +proc formatDevice*(dev: Device; showIcons: bool): string = + let icon = if showIcons and dev.icon.len > 0: dev.icon & " " else: "" + let displayName = if dev.alias.len > 0: dev.alias else: dev.name + let safeName = if displayName.len > 0: displayName else: dev.address + &"{icon}{safeName} {dev.status} {dev.address}" + +proc formatDeviceNoIcons*(dev: Device): string = + let displayName = if dev.alias.len > 0: dev.alias else: dev.name + let safeName = if displayName.len > 0: displayName else: dev.address + &"{safeName} {dev.status} {dev.address}" + +proc deviceToJson*(dev: Device): JsonNode = + result = %*{ + "address": dev.address, + "name": dev.name, + "alias": dev.alias, + "type": $dev.deviceType, + "status": $dev.status, + "trusted": dev.trusted, + "blocked": dev.blocked, + "paired": dev.paired, + "connected": dev.connected, + "rssi": dev.rssi + } + +proc adapterToJson*(ad: Adapter): JsonNode = + result = %*{ + "path": ad.path, + "address": ad.address, + "name": ad.name, + "powered": ad.powered, + "discovering": ad.discovering, + "discoverable": ad.discoverable + } + +proc formatList*(devices: seq[Device]; mode: string): string = + var lines: seq[string] + for dev in devices: + var d = dev + classifyDevice(d) + if d.icon.len == 0: + d.icon = defaultIcon(d.deviceType) + case mode + of "no-icons": + lines.add(formatDeviceNoIcons(d)) + of "json": + lines.add($deviceToJson(d)) + else: + lines.add(formatDevice(d, true)) + result = lines.join("\n") + +proc formatInfo*(dev: Device; ad: Adapter): string = + var d = dev + classifyDevice(d) + if d.icon.len == 0: + d.icon = defaultIcon(d.deviceType) + let displayName = if d.alias.len > 0: d.alias else: d.name + let safeName = if displayName.len > 0: displayName else: d.address + result = &"{d.icon} {safeName}\n" + result.add &" Address: {d.address}\n" + result.add &" Type: {d.deviceType}\n" + result.add &" Status: {d.status}\n" + result.add &" Trusted: {d.trusted}\n" + result.add &" Blocked: {d.blocked}\n" + result.add &" Paired: {d.paired}\n" + result.add &" Connected: {d.connected}\n" + result.add &" Adapter: {ad.name} ({ad.address})\n" + if d.uuids.len > 0: + result.add &" UUIDs: {d.uuids.join(\", \")}\n" diff --git a/src/bt_socket.nim b/src/bt_socket.nim new file mode 100644 index 0000000..da48777 --- /dev/null +++ b/src/bt_socket.nim @@ -0,0 +1,67 @@ +import os, posix, json, strutils, tables +import bt_types + +const SocketPathEnv* = "NIMBT_SOCKET" + +proc defaultSocketPath*: string = + let runtimeDir = getEnv("XDG_RUNTIME_DIR") + if runtimeDir.len > 0: + result = runtimeDir / "nimbt.sock" + else: + result = "/tmp/nimbt.sock" + +proc getSocketPath*: string = + result = getEnv(SocketPathEnv) + if result.len == 0: + result = defaultSocketPath() + +proc parseCmd*(line: string): SocketCmd = + let node = parseJson(line) + result.id = node["id"].getInt(0) + let cmdStr = node["cmd"].getStr("") + if cmdStr.len > 0: + result.cmd = parseEnum[SocketCmdKind](cmdStr, scList) + result.args = initTable[string, string]() + if node.hasKey("args") and node["args"].kind == JObject: + for k, v in node["args"].fields: + result.args[k] = v.getStr("") + +proc cmdToJson*(cmd: SocketCmd): string = + var argsNode = newJObject() + for k, v in cmd.args: + argsNode[k] = %v + let node = %*{"id": cmd.id, "cmd": $cmd.cmd, "args": argsNode} + result = $node & "\n" + +proc respToJson*(resp: SocketResp): string = + let node = %*{ + "id": resp.id, + "ok": resp.ok, + "data": resp.data, + "error": resp.error, + "stream": resp.stream + } + result = $node & "\n" + +proc sendResponse*(fd: cint; resp: SocketResp) = + let data = respToJson(resp) + discard write(fd, data.cstring, data.len) + +proc recvCommand*(fd: cint; timeoutMs: int = 100): SocketCmd = + var buf: array[8192, char] + var line = "" + while true: + var pfd: TPollfd + pfd.fd = fd + pfd.events = POLLIN + pfd.revents = 0 + let ret = poll(addr pfd, 1.Tnfds, timeoutMs.cint) + if ret <= 0: return + let n = read(fd, addr buf, buf.len) + if n <= 0: return + for i in 0.. 0: + return parseCmd(line) + else: + line.add(buf[i]) diff --git a/src/bt_types.nim b/src/bt_types.nim new file mode 100644 index 0000000..0dfa310 --- /dev/null +++ b/src/bt_types.nim @@ -0,0 +1,84 @@ +import tables + +type + DeviceType* = enum + dtUnknown = "unknown" + dtAudio = "audio" + dtKeyboard = "keyboard" + dtMouse = "mouse" + dtGamepad = "gamepad" + dtPhone = "phone" + dtPrinter = "printer" + dtCamera = "camera" + dtWearable = "wearable" + dtComputer = "computer" + dtNetwork = "network" + dtScanner = "scanner" + dtDisplay = "display" + dtToy = "toy" + dtHealth = "health" + + DeviceStatus* = enum + dsConnected = "connected" + dsDisconnected = "disconnected" + dsPaired = "paired" + dsConnecting = "connecting" + dsUnknown = "unknown" + + Device* = object + address*: string + name*: string + alias*: string + deviceType*: DeviceType + status*: DeviceStatus + trusted*: bool + blocked*: bool + paired*: bool + connected*: bool + rssi*: int + icon*: string + path*: string + uuids*: seq[string] + appearance*: int + + Adapter* = object + path*: string + address*: string + name*: string + powered*: bool + discovering*: bool + discoverable*: bool + pairable*: bool + + Config* = object + autoconnect*: bool + autoconnectTrustedOnly*: bool + autoconnectRetryInterval*: int + icons*: Table[DeviceType, string] + aliases*: Table[string, string] + defaultAdapter*: string + + SocketCmdKind* = enum + scList + scScan + scPower + scConnect + scDisconnect + scPair + scTrust + scRemove + scInfo + scMonitor + scAutoconnect + + SocketCmd* = object + id*: int + cmd*: SocketCmdKind + args*: Table[string, string] + + SocketResp* = object + id*: int + ok*: bool + data*: string + error*: string + stream*: bool diff --git a/src/nbt.nim b/src/nbt.nim new file mode 100644 index 0000000..55e812a --- /dev/null +++ b/src/nbt.nim @@ -0,0 +1,233 @@ +import os, posix, strutils, strformat, tables, json, times +import bt_types, bt_dbus, bt_device, bt_icons, bt_config, bt_socket + +proc printHelp() = + echo &"""nbt — Bluetooth CLI + +Usage: + nbt list [--no-icons|--json] + nbt scan [on|off] + nbt power [on|off|toggle] + nbt connect + nbt disconnect + nbt pair + nbt trust [on|off] + nbt remove + nbt info + nbt monitor + nbt autoconnect [] + nbt -h | --help + +Examples: + nbt list # list devices with icons + nbt list --no-icons # plain text for dmenu/rofi + nbt list --json # JSON for scripting + nbt connect XX:XX:XX:XX:XX:XX # connect device + nbt scan on # start discovery + """ + quit(0) + +proc getDirectBz(): BluezConn = + result = newBluezConn() + result.refresh() + +proc cmdList(args: Table[string, string]) = + let mode = args.getOrDefault("mode", "plain") + let bz = getDirectBz() + echo formatList(bz.devices, mode) + if bz.adapters.len > 0: + let ad = bz.adapters[0] + let state = if ad.powered: "on" else: "off" + let scanning = if ad.discovering: " (scanning)" else: "" + echo &"---\nAdapter: {ad.name} ({ad.address}) — {state}{scanning}" + +proc cmdScan(args: Table[string, string]) = + let onoff = args.getOrDefault("state", "on") == "on" + let bz = getDirectBz() + bz.scan(onoff) + let status = if onoff: "started" else: "stopped" + echo &"Scanning {status}" + +proc cmdPower(args: Table[string, string]) = + let state = args.getOrDefault("state", "on") + let bz = getDirectBz() + case state + of "on": bz.setPowered(true); echo "Powered on" + of "off": bz.setPowered(false); echo "Powered off" + of "toggle": + let ad = bz.getAdapter() + bz.setPowered(not ad.powered) + let pwr = if not ad.powered: "on" else: "off" + echo &"Powered {pwr}" + else: echo "usage: nbt power [on|off|toggle]" + +proc cmdConnect(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + if devAddr.len == 0: echo "usage: nbt connect "; return + let bz = getDirectBz() + try: + bz.connectDevice(devAddr) + echo "Connected" + except Exception as e: + echo "Error: ", e.msg + +proc cmdDisconnect(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + if devAddr.len == 0: echo "usage: nbt disconnect "; return + let bz = getDirectBz() + try: + bz.disconnectDevice(devAddr) + echo "Disconnected" + except Exception as e: + echo "Error: ", e.msg + +proc cmdPair(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + if devAddr.len == 0: echo "usage: nbt pair "; return + let bz = getDirectBz() + try: + bz.pairDevice(devAddr) + echo "Paired" + except Exception as e: + echo "Error: ", e.msg + +proc cmdTrust(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + let trust = args.getOrDefault("trust", "on") != "off" + if devAddr.len == 0: echo "usage: nbt trust [on|off]"; return + let bz = getDirectBz() + try: + bz.trustDevice(devAddr, trust) + let trustStr = if trust: "set" else: "unset" + echo &"Trust {trustStr}" + except Exception as e: + echo "Error: ", e.msg + +proc cmdRemove(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + if devAddr.len == 0: echo "usage: nbt remove "; return + let bz = getDirectBz() + try: + bz.removeDevice(devAddr) + echo "Removed" + except Exception as e: + echo "Error: ", e.msg + +proc cmdInfo(args: Table[string, string]) = + let devAddr = args.getOrDefault("addr", "") + if devAddr.len == 0: echo "usage: nbt info "; return + let bz = getDirectBz() + try: + let dev = bz.findDevice(devAddr) + if dev.path.len == 0: echo "Device not found"; return + let ad = bz.getAdapter() + echo formatInfo(dev, ad) + except Exception as e: + echo "Error: ", e.msg + +proc cmdMonitor() = + let bz = getDirectBz() + echo "Monitoring Bluetooth events (Ctrl+C to stop)..." + var lastRefresh = epochTime() + while true: + let now = epochTime() + if now - lastRefresh > 2.0: + lastRefresh = now + bz.refresh() + for d in bz.devices: + var dev = d + classifyDevice(dev) + if dev.icon.len == 0: + dev.icon = defaultIcon(dev.deviceType) + echo formatDevice(dev, true) + echo "---" + sleep(500) + +proc cmdAutoconnect(args: Table[string, string]) = + echo "Use `nbt trust` to mark devices for autoconnect" + +proc runDirect(cmd: string; args: Table[string, string]) = + case cmd + of "list": cmdList(args) + of "scan": cmdScan(args) + of "power": cmdPower(args) + of "connect": cmdConnect(args) + of "disconnect": cmdDisconnect(args) + of "pair": cmdPair(args) + of "trust": cmdTrust(args) + of "remove": cmdRemove(args) + of "info": cmdInfo(args) + of "monitor": cmdMonitor() + of "autoconnect": cmdAutoconnect(args) + else: printHelp() + +proc connectToDaemon(): cint = + let sockPath = getSocketPath() + if not fileExists(sockPath): + return -1 + let sock = posix.socket(AF_UNIX, SOCK_STREAM, 0) + if sock == INVALID_SOCKET: + return -1 + var saddr: SockAddr_un + saddr.sun_family = TSa_Family(AF_UNIX) + for i in 0.. 0: + let node = parseJson(respData) + if node.hasKey("data"): + echo node["data"].getStr("") + elif node.hasKey("error"): + echo "Error: ", node["error"].getStr("") + return true + else: + respData.add(buf[i]) + discard posix.close(fd) + return true + +when isMainModule: + let config = loadConfig() + let args = commandLineParams() + if args.len == 0 or args[0] in ["-h", "--help"]: + printHelp() + let cmd = args[0] + var cmdArgs: Table[string, string] + var i = 1 + while i < args.len: + if args[i] == "--no-icons": + cmdArgs["mode"] = "no-icons" + elif args[i] == "--json": + cmdArgs["mode"] = "json" + elif args[i] in ["on", "off", "toggle"]: + if cmd == "power" or cmd == "scan": + cmdArgs["state"] = args[i] + elif cmd == "trust": + cmdArgs["trust"] = args[i] + else: + if not args[i].startsWith("-"): + cmdArgs["addr"] = args[i] + i += 1 + if cmd == "monitor": + cmdMonitor() + else: + if not runViaDaemon(cmd, cmdArgs): + runDirect(cmd, cmdArgs) diff --git a/src/nimbt_daemon.nim b/src/nimbt_daemon.nim new file mode 100644 index 0000000..d99077b --- /dev/null +++ b/src/nimbt_daemon.nim @@ -0,0 +1,187 @@ +import os, posix, strformat, tables, times +import bt_types, bt_dbus, bt_device, bt_icons, bt_config, bt_socket, bt_autoconnect + +const Version = "0.1.0" + +var + daemonRunning = true + gConfig: Config + gBz: BluezConn + gAutoConnect: AutoConnectState + +proc handleSigint() {.noconv.} = + daemonRunning = false + +proc handleSocketCmd(fd: cint; cmd: SocketCmd) = + var resp = SocketResp(id: cmd.id, ok: true, stream: false) + try: + case cmd.cmd + of scList: + let mode = cmd.args.getOrDefault("mode", "plain") + var devices = gBz.devices + for i in 0.. 0: + let ad = gBz.getAdapter() + resp.data = formatInfo(dev, ad) + else: + resp.data = "device not found" + of scMonitor: + resp.stream = true + gBz.refresh() + resp.data = formatList(gBz.devices, "plain") + of scAutoconnect: + resp.data = "autoconnect enabled" + except Exception as e: + resp.ok = false + resp.error = e.msg + sendResponse(fd, resp) + +proc runDaemon() = + let sockPath = getSocketPath() + if fileExists(sockPath): + removeFile(sockPath) + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + if serverFd == INVALID_SOCKET: + echo "Failed to create socket" + quit(1) + + var saddr: SockAddr_un + saddr.sun_family = TSa_Family(AF_UNIX) + for i in 0.. 0 and (pollFds[0].revents and POLLIN) != 0: + var clientAddr: SockAddr_un + var clientLen = sizeof(clientAddr).SockLen + let cf = accept(serverFd, cast[ptr SockAddr](addr(clientAddr)), addr(clientLen)) + if cf != INVALID_SOCKET: + clientFds.add(int(cf).cint) + + var i = 1 + while i <= pollFds.len - 1: + let pf = pollFds[i] + if (pf.revents and POLLIN) != 0: + let cmd = recvCommand(pf.fd, 0) + if cmd.cmd != scList or (cmd.cmd == scList and cmd.id > 0): + handleSocketCmd(pf.fd, cmd) + discard posix.close(pf.fd) + clientFds.delete(i - 1) + continue + elif (pf.revents and (POLLHUP or POLLERR)) != 0: + discard posix.close(pf.fd) + clientFds.delete(i - 1) + continue + i += 1 + + if now - lastRefresh > 2.0: + lastRefresh = now + try: + gBz.refresh() + except: + discard + + if now - lastAutoTick > 1.0: + lastAutoTick = now + try: + tick(gAutoConnect, gBz) + except: + discard + + try: + discard dispatch(gBz, 0) + except: + discard + + for cf in clientFds: + discard posix.close(cf) + discard posix.close(serverFd) + if fileExists(sockPath): + removeFile(sockPath) + echo "nimbt-daemon stopped" + +when isMainModule: + setControlCHook(handleSigint) + gConfig = loadConfig() + gBz = newBluezConn() + gBz.refresh() + gAutoConnect = newAutoConnectState(gConfig) + runDaemon()