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:
commit
2601611a46
14 changed files with 1278 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
46
PKGBUILD
Normal 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
3
config.nims
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
--gc:orc
|
||||
--define:release
|
||||
--hints:off
|
||||
12
nimbt-daemon.service
Normal file
12
nimbt-daemon.service
Normal 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
12
nimbt.nimble
Normal 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
52
src/bt_autoconnect.nim
Normal 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
132
src/bt_config.nim
Normal 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
214
src/bt_dbus.nim
Normal 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
116
src/bt_device.nim
Normal 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
108
src/bt_icons.nim
Normal 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
67
src/bt_socket.nim
Normal 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
84
src/bt_types.nim
Normal 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
233
src/nbt.nim
Normal 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
187
src/nimbt_daemon.nim
Normal 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()
|
||||
Loading…
Add table
Reference in a new issue