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
This commit is contained in:
Niko Marmeladkov 2026-05-30 10:25:53 +03:00
commit 2601611a46
Signed by untrusted user who does not match committer: Niko
GPG key ID: E3B955F9442D44E3
14 changed files with 1278 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
*.o
nimbt
nimbt-daemon
nbt
nimbt_daemon
nimbt-daemon-d
nbt-d
nimbt_daemon-d
nbt-d
/D
*.exe
*.out

46
PKGBUILD Normal file
View file

@ -0,0 +1,46 @@
# Maintainer: nimbt <niko@n1kt.ru>
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
}

3
config.nims Normal file
View file

@ -0,0 +1,3 @@
--gc:orc
--define:release
--hints:off

12
nimbt-daemon.service Normal file
View file

@ -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

12
nimbt.nimble Normal file
View file

@ -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"

52
src/bt_autoconnect.nim Normal file
View file

@ -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

132
src/bt_config.nim Normal file
View file

@ -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]()
)

214
src/bt_dbus.nim Normal file
View file

@ -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

116
src/bt_device.nim Normal file
View file

@ -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)

108
src/bt_icons.nim Normal file
View file

@ -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"

67
src/bt_socket.nim Normal file
View file

@ -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..<n:
if buf[i] == '\n':
if line.len > 0:
return parseCmd(line)
else:
line.add(buf[i])

84
src/bt_types.nim Normal file
View file

@ -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

233
src/nbt.nim Normal file
View file

@ -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 <addr>
nbt disconnect <addr>
nbt pair <addr>
nbt trust <addr> [on|off]
nbt remove <addr>
nbt info <addr>
nbt monitor
nbt autoconnect [<addr>]
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 <addr>"; 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 <addr>"; 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 <addr>"; 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 <addr> [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 <addr>"; 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 <addr>"; 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..<min(sockPath.len, saddr.sun_path.len):
saddr.sun_path[i] = sockPath[i]
let clen = sizeof(saddr.sun_family) + sockPath.len
if posix.connect(sock, cast[ptr SockAddr](addr(saddr)), SockLen(clen)) < 0:
discard posix.close(sock)
return -1
result = int(sock).cint
proc runViaDaemon(cmd: string; args: Table[string, string]): bool =
let fd = connectToDaemon()
if fd < 0:
return false
let sockCmd = SocketCmd(id: 1, cmd: parseEnum[SocketCmdKind](cmd, scList), args: args)
let reqJson = cmdToJson(sockCmd)
discard write(fd, reqJson.cstring, reqJson.len)
var respData = ""
var buf: array[4096, char]
while true:
let n = read(fd, addr buf, buf.len)
if n <= 0: break
for i in 0..<n:
if buf[i] == '\n':
if respData.len > 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)

187
src/nimbt_daemon.nim Normal file
View file

@ -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..<devices.len:
classifyDevice(devices[i])
if devices[i].icon.len == 0:
devices[i].icon = defaultIcon(devices[i].deviceType)
resp.data = formatList(devices, mode)
of scScan:
let onoff = cmd.args.getOrDefault("state", "on") == "on"
gBz.scan(onoff)
resp.data = "ok"
of scPower:
let state = cmd.args.getOrDefault("state", "on")
case state
of "on": gBz.setPowered(true)
of "off": gBz.setPowered(false)
of "toggle":
let ad = gBz.getAdapter()
gBz.setPowered(not ad.powered)
else: discard
resp.data = "ok"
of scConnect:
gBz.connectDevice(cmd.args["addr"])
resp.data = "connected"
of scDisconnect:
gBz.disconnectDevice(cmd.args["addr"])
resp.data = "disconnected"
of scPair:
gBz.pairDevice(cmd.args["addr"])
resp.data = "paired"
of scTrust:
let trust = cmd.args.getOrDefault("trust", "on") != "off"
gBz.trustDevice(cmd.args["addr"], trust)
resp.data = "ok"
of scRemove:
gBz.removeDevice(cmd.args["addr"])
resp.data = "removed"
of scInfo:
let dev = gBz.findDevice(cmd.args["addr"])
if dev.path.len > 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..<min(sockPath.len, saddr.sun_path.len):
saddr.sun_path[i] = sockPath[i]
let alen = sizeof(saddr.sun_family) + sockPath.len
if bindSocket(serverFd, cast[ptr SockAddr](addr(saddr)), SockLen(alen)) < 0:
echo "Failed to bind socket: ", sockPath
discard posix.close(serverFd)
quit(1)
if listen(serverFd, 5) < 0:
echo "Failed to listen on socket"
discard posix.close(serverFd)
quit(1)
let serverFdInt = int(serverFd).cint
setFilePermissions(sockPath, {fpUserRead, fpUserWrite, fpGroupRead, fpGroupWrite, fpOthersRead, fpOthersWrite})
var clientFds: seq[cint] = @[]
echo &"nimbt-daemon v{Version} started"
echo &"Socket: {sockPath}"
echo &"Adapter: {gBz.adapters[0].name} ({gBz.adapters[0].address})"
var lastRefresh = epochTime()
var lastAutoTick = epochTime()
while daemonRunning:
let now = epochTime()
var pfd: TPollfd
pfd.fd = serverFdInt
pfd.events = POLLIN
pfd.revents = 0
var pollFds: seq[TPollfd] = @[pfd]
for cf in clientFds:
pollFds.add(TPollfd(fd: int(cf).cint, events: POLLIN, revents: 0))
let pret = poll(addr pollFds[0], pollFds.len.Tnfds, 200.cint)
if pret < 0:
if osLastError() != OSErrorCode(EINTR):
sleep(100)
continue
if pret > 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()