223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
import { classifySignal, SignalClass, ThresholdConfig } from './thresholds.js';
|
|
|
|
const BLUEZ_SERVICE = 'org.bluez';
|
|
const ADAPTER_PATH = '/org/bluez/hci0';
|
|
const ADAPTER_IFACE = 'org.bluez.Adapter1';
|
|
const DEVICE_IFACE = 'org.bluez.Device1';
|
|
const OBJECT_MANAGER_IFACE = 'org.freedesktop.DBus.ObjectManager';
|
|
|
|
function addressToPath(address: string): string {
|
|
return `${ADAPTER_PATH}/dev_${address.replace(/:/g, '_')}`;
|
|
}
|
|
|
|
export class BleMonitor extends GObject.Object {
|
|
static {
|
|
GObject.registerClass({
|
|
GTypeName: 'BleUnlockMonitor',
|
|
Signals: {
|
|
'rssi-changed': { param_types: [GObject.TYPE_INT] },
|
|
'devices-changed': { param_types: [] },
|
|
},
|
|
}, this);
|
|
}
|
|
|
|
private _deviceAddress: string = '';
|
|
private _config: ThresholdConfig = { rssiLockThreshold: -75, rssiUnlockThreshold: -65 };
|
|
private _pollIntervalSec: number = 3;
|
|
private _timerId: number | null = null;
|
|
private _deviceProxy: Gio.DBusProxy | null = null;
|
|
private _adapterProxy: Gio.DBusProxy | null = null;
|
|
private _lastClass: SignalClass | null = null;
|
|
private _knownDevices: Map<string, string> = new Map();
|
|
|
|
/**
|
|
* Start monitoring the given device address.
|
|
*/
|
|
startMonitoring(deviceAddress: string, config: ThresholdConfig, pollIntervalSec: number): void {
|
|
this._deviceAddress = deviceAddress;
|
|
this._config = config;
|
|
this._pollIntervalSec = pollIntervalSec;
|
|
|
|
const bus = Gio.DBus.system;
|
|
|
|
try {
|
|
this._adapterProxy = Gio.DBusProxy.new_sync(
|
|
bus,
|
|
Gio.DBusProxyFlags.NONE,
|
|
null,
|
|
BLUEZ_SERVICE,
|
|
ADAPTER_PATH,
|
|
ADAPTER_IFACE,
|
|
null,
|
|
);
|
|
} catch (e) {
|
|
console.error(`[BleUnlock] Failed to create adapter proxy: ${e}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this._deviceProxy = Gio.DBusProxy.new_sync(
|
|
bus,
|
|
Gio.DBusProxyFlags.NONE,
|
|
null,
|
|
BLUEZ_SERVICE,
|
|
addressToPath(deviceAddress),
|
|
DEVICE_IFACE,
|
|
null,
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[BleUnlock] Failed to create device proxy for ${deviceAddress}: ${e}`);
|
|
}
|
|
|
|
this._startDiscovery();
|
|
this._refreshDevices();
|
|
|
|
this._timerId = GLib.timeout_add_seconds(
|
|
GLib.PRIORITY_DEFAULT,
|
|
this._pollIntervalSec,
|
|
() => this._poll(),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stop monitoring and clean up D-Bus resources.
|
|
*/
|
|
stopMonitoring(): void {
|
|
if (this._timerId !== null) {
|
|
GLib.Source.remove(this._timerId);
|
|
this._timerId = null;
|
|
}
|
|
this._stopDiscovery();
|
|
this._deviceProxy = null;
|
|
this._adapterProxy = null;
|
|
this._lastClass = null;
|
|
}
|
|
|
|
/** Returns the map of known BT devices (address → name). */
|
|
get devices(): Map<string, string> {
|
|
return this._knownDevices;
|
|
}
|
|
|
|
/** Called when RSSI drops below lock threshold. Wired by extension.ts. */
|
|
onSignalBelowThreshold(): void {}
|
|
|
|
/** Called when RSSI rises above unlock threshold. Wired by extension.ts. */
|
|
onSignalAboveThreshold(): void {}
|
|
|
|
private _startDiscovery(): void {
|
|
if (!this._adapterProxy) return;
|
|
try {
|
|
this._adapterProxy.call(
|
|
'StartDiscovery',
|
|
null,
|
|
Gio.DBusCallFlags.NONE,
|
|
-1,
|
|
null,
|
|
null,
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[BleUnlock] StartDiscovery failed: ${e}`);
|
|
}
|
|
}
|
|
|
|
private _stopDiscovery(): void {
|
|
if (!this._adapterProxy) return;
|
|
try {
|
|
this._adapterProxy.call(
|
|
'StopDiscovery',
|
|
null,
|
|
Gio.DBusCallFlags.NONE,
|
|
-1,
|
|
null,
|
|
null,
|
|
);
|
|
} catch (e) {
|
|
console.warn(`[BleUnlock] StopDiscovery failed: ${e}`);
|
|
}
|
|
}
|
|
|
|
private _refreshDevices(): void {
|
|
const bus = Gio.DBus.system;
|
|
try {
|
|
const result = bus.call_sync(
|
|
BLUEZ_SERVICE,
|
|
'/',
|
|
OBJECT_MANAGER_IFACE,
|
|
'GetManagedObjects',
|
|
null,
|
|
null,
|
|
Gio.DBusCallFlags.NONE,
|
|
-1,
|
|
null,
|
|
);
|
|
|
|
if (!result) return;
|
|
|
|
const objects = result.get_child_value(0);
|
|
const nObjects = objects.n_children();
|
|
this._knownDevices.clear();
|
|
|
|
for (let i = 0; i < nObjects; i++) {
|
|
const entry = objects.get_child_value(i);
|
|
const ifaces = entry.get_child_value(1);
|
|
const nIfaces = ifaces.n_children();
|
|
|
|
for (let j = 0; j < nIfaces; j++) {
|
|
const ifaceEntry = ifaces.get_child_value(j);
|
|
const ifaceName = ifaceEntry.get_child_value(0).get_string()[0];
|
|
if (ifaceName !== DEVICE_IFACE) continue;
|
|
|
|
const props = ifaceEntry.get_child_value(1);
|
|
const nProps = props.n_children();
|
|
let address: string | null = null;
|
|
let name: string = '';
|
|
|
|
for (let k = 0; k < nProps; k++) {
|
|
const prop = props.get_child_value(k);
|
|
const propName = prop.get_child_value(0).get_string()[0];
|
|
const propValue = prop.get_child_value(1).get_child_value(0);
|
|
if (propName === 'Address') address = propValue.get_string()[0];
|
|
if (propName === 'Name') name = propValue.get_string()[0];
|
|
}
|
|
|
|
if (address) this._knownDevices.set(address, name);
|
|
}
|
|
}
|
|
|
|
this.emit('devices-changed');
|
|
} catch (e) {
|
|
console.warn(`[BleUnlock] GetManagedObjects failed: ${e}`);
|
|
}
|
|
}
|
|
|
|
private _poll(): boolean {
|
|
let rssi: number | null = null;
|
|
|
|
if (this._deviceProxy) {
|
|
const variant = this._deviceProxy.get_cached_property('RSSI');
|
|
if (variant) {
|
|
rssi = variant.get_int16();
|
|
}
|
|
}
|
|
|
|
const currentClass = classifySignal(rssi, this._config);
|
|
this.emit('rssi-changed', rssi ?? 0);
|
|
|
|
if (this._lastClass !== currentClass) {
|
|
if (currentClass === 'absent' && this._lastClass !== null) {
|
|
console.debug(`[BleUnlock] Signal below threshold (RSSI=${rssi}), locking`);
|
|
this.onSignalBelowThreshold();
|
|
} else if (currentClass === 'strong' && this._lastClass !== 'strong') {
|
|
console.debug(`[BleUnlock] Signal above threshold (RSSI=${rssi}), unlocking`);
|
|
this.onSignalAboveThreshold();
|
|
}
|
|
this._lastClass = currentClass;
|
|
}
|
|
|
|
return GLib.SOURCE_CONTINUE;
|
|
}
|
|
}
|