/**
* mcp/skills/listBluetoothDevices.ts — list_bluetooth_devices skill
*
* Enumerates paired Bluetooth devices with name, address, connection
* state, and (where surfaced by the OS) RSSI + battery level. Used by
* the P0-d A/V & Peripheral Repair skill to flag flaky / disconnected
* Bluetooth peripherals (AirPods, mice, keyboards) before deciding
* whether to reset the Bluetooth module.
*
* Platform strategy
* -----------------
* darwin `system_profiler SPBluetoothDataType -json`. The macOS report
* has a specific shape: a tree where the top-level entry has
* "device_connected" (paired AND currently connected) and
* "device_not_connected" (paired but offline) sub-objects.
* win32 PowerShell `Get-PnpDevice -Class Bluetooth` for the device
* list; battery + RSSI are not consistently exposed by Windows
* APIs from PowerShell, so those fields are reported as
* undefined on win32 unless `BluetoothLEAdvertisement` reports
* them (out of scope for the alpha).
*
* Read-only — device pair / unpair / module reset are separate tools.
*/
import * as os from "os" ;
import { z } from "zod" ;
import {
execAsync,
runPS,
} from "./_shared/platform" ;
// -- Meta ---------------------------------------------------------------------
export const meta = {
name: "list_bluetooth_devices" ,
description:
"Enumerates paired Bluetooth devices (connected and offline) with name, " +
"address, connection state, and where supported RSSI + battery percent. " +
"Use when troubleshooting flaky Bluetooth peripherals — a paired device " +
"showing as not-connected when the user expects it to be active is the " +
"primary signal. Read-only." ,
riskLevel: "low" ,
destructive: false ,
requiresConsent: false ,
supportsDryRun: false ,
affectedScope: [ "user" ],
auditRequired: false ,
schema: {} as Record < string , z . ZodTypeAny >,
} as const ;
// -- Types --------------------------------------------------------------------
export interface BluetoothDevice {
name : string ;
address ?: string ;
/** Whether the device is currently connected. */
connected : boolean ;
/** Whether the device is paired (always true on darwin output, may be inferred on win32). */
paired : boolean ;
/** RSSI in dBm if reported by the OS. */
rssi ?: number ;
/** Battery percentage if reported. */
batteryPercent ?: number ;
/** Vendor ID where surfaced. */
vendorId ?: string ;
productId ?: string ;
}
export interface ListBluetoothDevicesResult {
platform : NodeJS . Platform ;
poweredOn : boolean ;
total : number ;
devices : BluetoothDevice [];
}
// -- darwin implementation ----------------------------------------------------
interface SPBluetoothEntry {
/** Each device is a single-key object: { "<deviceName>": { ...details } } */
[ name : string ] : {
device_address ?: string ;
device_minorClassOfDevice_string ?: string ;
device_majorClassOfDevice_string ?: string ;
device_battery_level_main ?: string ; // "85%"
device_rssi ?: string ;
device_vendorID ?: string ;
device_productID ?: string ;
};
}
interface SPBluetoothControllerInfo {
controller_state ?: string ; // "On" | "Off"
device_title ?: string ;
general_device_status ?: string ;
}
interface SPBluetoothBlock {
device_title ?: string ;
controller_properties ?: SPBluetoothControllerInfo ;
device_connected ?: SPBluetoothEntry [];
device_not_connected ?: SPBluetoothEntry [];
}
interface SPBluetoothData {
SPBluetoothDataType ?: SPBluetoothBlock [];
}
function parseBatteryDarwin ( raw : string | undefined ) : number | undefined {
if ( ! raw) return undefined ;
const match = raw. match ( / ( \d + ) \s * % ? / );
if ( ! match) return undefined ;
const n = parseInt (match[ 1 ], 10 );
return Number. isNaN (n) ? undefined : n;
}
function parseRssiDarwin ( raw : string | undefined ) : number | undefined {
if ( ! raw) return undefined ;
const match = raw. match ( / - ? \d + / );
if ( ! match) return undefined ;
const n = parseInt (match[ 0 ], 10 );
return Number. isNaN (n) ? undefined : n;
}
function flattenDarwinDevices ( entries : SPBluetoothEntry [], connected : boolean , out : BluetoothDevice []) : void {
for ( const entry of entries) {
for ( const [ name , details ] of Object. entries (entry)) {
const battery = parseBatteryDarwin (details.device_battery_level_main);
const rssi = parseRssiDarwin (details.device_rssi);
const dev : BluetoothDevice = {
name,
... (details.device_address && { address: details.device_address }),
connected,
paired: true ,
... (battery !== undefined && { batteryPercent: battery }),
... (rssi !== undefined && { rssi }),
... (details.device_vendorID && { vendorId: details.device_vendorID }),
... (details.device_productID && { productId: details.device_productID }),
};
out. push (dev);
}
}
}
function parseDarwinOutput ( stdout : string ) : ListBluetoothDevicesResult {
const data = JSON . parse (stdout) as SPBluetoothData ;
const blocks = data.SPBluetoothDataType ?? [];
const devices : BluetoothDevice [] = [];
let poweredOn = false ;
for ( const block of blocks) {
if (block.controller_properties?.controller_state === "On" ) poweredOn = true ;
if (block.device_connected) flattenDarwinDevices (block.device_connected, true , devices);
if (block.device_not_connected) flattenDarwinDevices (block.device_not_connected, false , devices);
}
return {
platform: "darwin" ,
poweredOn,
total: devices. length ,
devices,
};
}
async function listBluetoothDarwin () : Promise < ListBluetoothDevicesResult > {
const { stdout } = await execAsync (
"system_profiler SPBluetoothDataType -json 2>/dev/null" ,
{ maxBuffer: 10 * 1024 * 1024 },
);
return parseDarwinOutput (stdout);
}
// -- win32 implementation -----------------------------------------------------
interface WinBluetoothDevice {
Name : string ;
Class : string ;
Status : string ; // "OK" | "Error" | "Unknown"
Present : boolean ;
InstanceId ?: string ; // contains BTHENUM\Dev_<address>
Manufacturer ?: string ;
}
function extractWinAddress ( instanceId : string | undefined ) : string | undefined {
if ( ! instanceId) return undefined ;
// BTHENUM\Dev_<12-hex-digit-address>\...
const match = instanceId. match ( / Dev_( [0-9A-Fa-f] {12} ) / );
if ( ! match) return undefined ;
// Format as XX:XX:XX:XX:XX:XX
const hex = match[ 1 ]. toUpperCase ();
return `${ hex . slice ( 0 , 2 ) }:${ hex . slice ( 2 , 4 ) }:${ hex . slice ( 4 , 6 ) }:${ hex . slice ( 6 , 8 ) }:${ hex . slice ( 8 , 10 ) }:${ hex . slice ( 10 , 12 ) }` ;
}
function parseWinOutput ( stdout : string , controllerOk : boolean ) : ListBluetoothDevicesResult {
const parsed = stdout. trim ()
? ( JSON . parse (stdout) as WinBluetoothDevice | WinBluetoothDevice [])
: [];
const raw : WinBluetoothDevice [] = Array. isArray (parsed) ? parsed : [parsed];
const devices : BluetoothDevice [] = raw
. filter (( d ) => d?.Name && d.Class === "Bluetooth" )
. map (( d ) => {
const address = extractWinAddress (d.InstanceId);
// PnP "Present + Status=OK" maps to connected; "Present + Status=Unknown"
// typically means paired-but-asleep (e.g. AirPods in case).
const connected = d.Present && d.Status === "OK" ;
const paired = d.Present;
return {
name: d.Name,
... (address && { address }),
connected,
paired,
};
});
return {
platform: "win32" ,
poweredOn: controllerOk,
total: devices. length ,
devices,
};
}
async function listBluetoothWin32 () : Promise < ListBluetoothDevicesResult > {
const devicesScript = `
$ErrorActionPreference = 'SilentlyContinue'
Get-PnpDevice -Class Bluetooth |
Select-Object Name, Class, Status, Present, InstanceId, Manufacturer |
ConvertTo-Json -Depth 2 -Compress` . trim ();
const controllerScript = `
$ErrorActionPreference = 'SilentlyContinue'
$svc = Get-Service -Name bthserv -ErrorAction SilentlyContinue
if ($svc -and $svc.Status -eq 'Running') { 'on' } else { 'off' }` . trim ();
const [ rawDevices , rawController ] = await Promise . all ([
runPS (devicesScript),
runPS (controllerScript),
]);
const controllerOk = rawController. trim (). toLowerCase () === "on" ;
return parseWinOutput (rawDevices, controllerOk);
}
// -- Exported run function ----------------------------------------------------
export async function run () : Promise < ListBluetoothDevicesResult > {
const platform = os. platform ();
if (platform === "darwin" ) return listBluetoothDarwin ();
if (platform === "win32" ) return listBluetoothWin32 ();
throw new Error ( `list_bluetooth_devices: unsupported platform "${ platform }"` );
}
// -- Test helpers -------------------------------------------------------------
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
parseDarwinOutput,
parseWinOutput,
parseBatteryDarwin,
parseRssiDarwin,
extractWinAddress,
flattenDarwinDevices,
};