Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
a0de261
feat(bluetooth): show BBM per-component battery and ANC controls
silentone12725 Aug 20, 2026
7e96d6b
BbmExpandedRow: icon-only ANC pills, animated NC levels
silentone12725 Aug 20, 2026
a13f9a6
BbmExpandedRow: fix icon colours + wider popout
silentone12725 Aug 20, 2026
a30e29e
BbmExpandedRow: universality fixes + 2px panel margins
silentone12725 Aug 20, 2026
0f5b7a5
BbmIcon: dynamic source colour via ImageAnalyser
silentone12725 Aug 20, 2026
744bb34
ponytail-audit: eliminate hardcoded values
silentone12725 Aug 20, 2026
a3faae7
nix: add glib to runtimeDeps for gdbus
silentone12725 Aug 20, 2026
317473e
Bluetooth: reduce popout width 360 → 320
silentone12725 Aug 20, 2026
def987e
Integrate BBM daemon into caelestia-shell with Nix home-manager support
silentone12725 Aug 20, 2026
9e14d29
Fix padding alignment and live D-Bus update buffering
silentone12725 Aug 20, 2026
ec07674
bbm: sync orchestrator reconnect fix
silentone12725 Aug 20, 2026
00e8661
bbm: sync reconnect and UI polish
silentone12725 Aug 20, 2026
7d2d441
Add generic bluetooth battery controls and status fixes
silentone12725 Aug 20, 2026
e3a1be7
bbm: deduplicate helpers and constants across BBM QML files
silentone12725 Aug 24, 2026
6ea996f
tests: add BBM unit tests (Node.js + QML)
silentone12725 Aug 24, 2026
748bc3e
bbm: add BtDeviceDefaultsPage for per-device BT settings
silentone12725 Aug 24, 2026
4512dc1
style: qmlformat all BBM QML files
silentone12725 Aug 24, 2026
97f624c
style: fix qml-lint-conventions violations in BBM files
silentone12725 Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
11 changes: 10 additions & 1 deletion .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@ watch_file plugin/**/*.vert
watch_file plugin/**/*.frag
watch_file **/CMakeLists.txt

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_CXX_COMPILER=clazy -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DDISTRIBUTOR=direnv
CXX_COMPILER="clazy"
if ! command -v "$CXX_COMPILER" >/dev/null 2>&1; then
CXX_COMPILER="clang++"
fi

cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DCMAKE_CXX_COMPILER="$CXX_COMPILER" \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=ON \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DDISTRIBUTOR=direnv
cmake --build build
export CAELESTIA_LIB_DIR="$PWD/build/lib"
export QML2_IMPORT_PATH="$PWD/build/qml:${QML2_IMPORT_PATH:-}"
6 changes: 5 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ project(caelestia-shell VERSION ${VERSION} LANGUAGES CXX)

include(GNUInstallDirs)

# Ninja + Qt QML modules fail when install RPATHs would require relinking from the build tree.
# Build with the install RPATH already active to avoid that relink step on ELF platforms.
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
Expand Down Expand Up @@ -62,7 +66,7 @@ if("plugin" IN_LIST ENABLE_MODULES)
endif()

if("shell" IN_LIST ENABLE_MODULES)
foreach(dir assets components modules services utils)
foreach(dir assets components modules services utils bbm)
install(DIRECTORY ${dir} DESTINATION "${INSTALL_QSCONFDIR}")
endforeach()

Expand Down
17 changes: 17 additions & 0 deletions bbm/daemon/bbm-daemon.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[Unit]
Description=Bluetooth Battery Meter daemon
Documentation=https://github.com/maniacx/Bluetooth-Battery-Meter
After=bluetooth.target graphical-session.target
PartOf=graphical-session.target

[Service]
Type=simple
# %h expands to $HOME. Adjust ExecStart path to match your install location.
ExecStart=/usr/bin/gjs -m %h/.local/share/bbm-daemon/daemon/main.js
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=graphical-session.target
206 changes: 206 additions & 0 deletions bbm/daemon/bluezEnumerator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
'use strict';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';

// Properties on org.bluez.Device1 that we care about.
const DEVICE_PROPS = new Set(['Connected', 'Paired', 'Alias', 'Name', 'Icon',
'UUIDs', 'Modalias', 'ServicesResolved']);

// Properties whose change should trigger a full sync (connect/disconnect lifecycle).
const LIFECYCLE_PROPS = new Set(['Connected']);

// Properties whose change should update DeviceRecord metadata only.
const METADATA_PROPS = new Set(['Alias', 'Name', 'Icon']);

/**
* Watches the BlueZ ObjectManager for device add/remove/change events.
* Replaces the GnomeBluetooth.Client dependency.
*
* Emits GObject signals:
* device-added(path: string)
* device-removed(path: string)
* device-changed(path: string) — metadata change only (alias, icon, etc.)
* device-lifecycle(path: string) — Connected state changed → triggers full sync
*/
export const BluezEnumerator = GObject.registerClass({
GTypeName: 'BBMDaemon_BluezEnumerator',
Signals: {
'device-added': {param_types: [GObject.TYPE_STRING]},
'device-removed': {param_types: [GObject.TYPE_STRING]},
'device-changed': {param_types: [GObject.TYPE_STRING]},
'device-lifecycle': {param_types: [GObject.TYPE_STRING]},
},
}, class BluezEnumerator extends GObject.Object {
_init() {
super._init();
// Map<bluezPath, deviceInfo>
this._devices = new Map();
this._systemBus = null;
this._objManagerProxy = null;
this._ifacesAddedId = null;
this._ifacesRemovedId = null;
// Map<bluezPath, signalSubscriptionId>
this._propsChangedIds = new Map();
}

async start() {
this._systemBus = Gio.bus_get_sync(Gio.BusType.SYSTEM, null);

// Connect to ObjectManager signals for device add/remove
this._ifacesAddedId = this._systemBus.signal_subscribe(
'org.bluez', 'org.freedesktop.DBus.ObjectManager',
'InterfacesAdded', null, null,
Gio.DBusSignalFlags.NONE,
this._onInterfacesAdded.bind(this)
);
this._ifacesRemovedId = this._systemBus.signal_subscribe(
'org.bluez', 'org.freedesktop.DBus.ObjectManager',
'InterfacesRemoved', null, null,
Gio.DBusSignalFlags.NONE,
this._onInterfacesRemoved.bind(this)
);

// Initial enumeration (synchronous — called once at startup before the main loop)
const result = this._systemBus.call_sync(
'org.bluez', '/', 'org.freedesktop.DBus.ObjectManager',
'GetManagedObjects', null, null,
Gio.DBusCallFlags.NONE, -1, null
);

const objects = result.deepUnpack()[0];
for (const [path, interfaces] of Object.entries(objects)) {
if ('org.bluez.Device1' in interfaces)
this._addDevice(path, interfaces['org.bluez.Device1']);
}
}

getDevices() {
return this._devices;
}

_pathToAddress(path) {
// /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF → AA:BB:CC:DD:EE:FF
const match = path.match(/dev_([0-9A-F_]+)$/i);
return match ? match[1].replace(/_/g, ':') : path;
}

_extractDeviceInfo(path, propsVariant) {
const p = propsVariant;
return {
address: this._pathToAddress(path),
alias: p.Alias?.deepUnpack() ?? p.Name?.deepUnpack() ?? 'Unknown',
connected: p.Connected?.deepUnpack() ?? false,
paired: p.Paired?.deepUnpack() ?? false,
icon: p.Icon?.deepUnpack() ?? '',
uuids: p.UUIDs?.deepUnpack() ?? [],
modalias: p.Modalias?.deepUnpack() ?? null,
servicesResolved: p.ServicesResolved?.deepUnpack() ?? false,
};
}

_addDevice(path, propsVariant) {
const info = this._extractDeviceInfo(path, propsVariant);
this._devices.set(path, info);
this._subscribePropsChanged(path);
this.emit('device-added', path);
}

_removeDevice(path) {
if (!this._devices.has(path))
return;
this._unsubscribePropsChanged(path);
this._devices.delete(path);
this.emit('device-removed', path);
}

_subscribePropsChanged(path) {
if (this._propsChangedIds.has(path))
return;
const id = this._systemBus.signal_subscribe(
'org.bluez',
'org.freedesktop.DBus.Properties',
'PropertiesChanged',
path,
'org.bluez.Device1',
Gio.DBusSignalFlags.NONE,
(_conn, _sender, _opath, _iface, _signal, params) => {
this._onPropsChanged(path, params);
}
);
this._propsChangedIds.set(path, id);
}

_unsubscribePropsChanged(path) {
const id = this._propsChangedIds.get(path);
if (id !== undefined) {
this._systemBus.signal_unsubscribe(id);
this._propsChangedIds.delete(path);
}
}

_onInterfacesAdded(_conn, _sender, _path, _iface, _signal, params) {
const [objectPath, interfaces] = params.deepUnpack();
if ('org.bluez.Device1' in interfaces)
this._addDevice(objectPath, interfaces['org.bluez.Device1']);
}

_onInterfacesRemoved(_conn, _sender, _path, _iface, _signal, params) {
const [objectPath, removedIfaces] = params.deepUnpack();
if (removedIfaces.includes('org.bluez.Device1'))
this._removeDevice(objectPath);
}

_onPropsChanged(path, params) {
const [, changedProps] = params.deepUnpack();
const info = this._devices.get(path);
if (!info)
return;

let lifecycleChanged = false;
let metadataChanged = false;

for (const [key, variant] of Object.entries(changedProps)) {
if (!DEVICE_PROPS.has(key))
continue; // Ignore RSSI, TxPower, ManufacturerData, etc.

const value = variant.deepUnpack();

if (LIFECYCLE_PROPS.has(key)) {
info[key.toLowerCase()] = value;
lifecycleChanged = true;
} else if (METADATA_PROPS.has(key)) {
if (key === 'Alias' || key === 'Name')
info.alias = value;
else if (key === 'Icon')
info.icon = value;
metadataChanged = true;
} else {
// UUIDs, Modalias, ServicesResolved — update but don't emit lifecycle
if (key === 'UUIDs') info.uuids = value;
else if (key === 'Modalias') info.modalias = value;
else if (key === 'ServicesResolved') info.servicesResolved = value;
else if (key === 'Paired') info.paired = value;
}
}

if (lifecycleChanged)
this.emit('device-lifecycle', path);
else if (metadataChanged)
this.emit('device-changed', path);
}

destroy() {
if (this._systemBus) {
if (this._ifacesAddedId !== null)
this._systemBus.signal_unsubscribe(this._ifacesAddedId);
if (this._ifacesRemovedId !== null)
this._systemBus.signal_unsubscribe(this._ifacesRemovedId);
for (const id of this._propsChangedIds.values())
this._systemBus.signal_unsubscribe(id);
}
this._propsChangedIds.clear();
this._devices.clear();
this._systemBus = null;
}
});
Loading
Loading