diff --git a/.envrc b/.envrc index 5a259560ac..d00a8395ca 100644 --- a/.envrc +++ b/.envrc @@ -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:-}" diff --git a/CMakeLists.txt b/CMakeLists.txt index 48ad47967a..ff5dd49372 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) @@ -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() diff --git a/bbm/daemon/bbm-daemon.service b/bbm/daemon/bbm-daemon.service new file mode 100644 index 0000000000..c1e2ebe480 --- /dev/null +++ b/bbm/daemon/bbm-daemon.service @@ -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 diff --git a/bbm/daemon/bluezEnumerator.js b/bbm/daemon/bluezEnumerator.js new file mode 100644 index 0000000000..c237f19a8e --- /dev/null +++ b/bbm/daemon/bluezEnumerator.js @@ -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 + this._devices = new Map(); + this._systemBus = null; + this._objManagerProxy = null; + this._ifacesAddedId = null; + this._ifacesRemovedId = null; + // Map + 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; + } +}); diff --git a/bbm/daemon/dbusService.js b/bbm/daemon/dbusService.js new file mode 100644 index 0000000000..9f06ed5143 --- /dev/null +++ b/bbm/daemon/dbusService.js @@ -0,0 +1,225 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +const BUS_NAME = 'org.bbm'; +const OBJECT_PATH = '/org/bbm'; +const INTERFACE_NAME = 'org.bbm.Manager'; + +const INTERFACE_XML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +/** + * Owns the org.bbm bus name on the session bus and serves the Manager interface. + * All D-Bus clients see only DeviceRecords — never protocol objects or DataHandlers. + */ +export class DbusService { + constructor(iconBasePath = '') { + this._iconBasePath = iconBasePath; + // Map + this._devices = new Map(); + this._sessionBus = null; + this._busNameId = null; + this._registrationId = null; + this._connection = null; + this._nodeInfo = Gio.DBusNodeInfo.new_for_xml(INTERFACE_XML); + } + + start() { + return new Promise((resolve, reject) => { + this._busNameId = Gio.bus_own_name( + Gio.BusType.SESSION, + BUS_NAME, + Gio.BusNameOwnerFlags.NONE, + (conn) => { + this._connection = conn; + try { + this._registrationId = conn.register_object( + OBJECT_PATH, + this._nodeInfo.interfaces[0], + this._handleMethodCall.bind(this), + null, // get_property + null // set_property + ); + console.log('BBM: D-Bus service registered at', OBJECT_PATH); + resolve(); + } catch (e) { + reject(e); + } + }, + null, // name_acquired (not needed; bus_acquired fires first) + (conn, name) => { + console.warn('BBM: lost bus name:', name); + } + ); + }); + } + + // Called by orchestrator when a device's protocol handshake completes. + addDevice(address, record, dataHandler) { + this._devices.set(address, {record, dataHandler}); + this._emitSignal('DeviceAdded', new GLib.Variant('(s)', [address])); + } + + // Called by orchestrator when a device reconnects with a fresh DataHandler. + updateDeviceHandler(address, dataHandler) { + const entry = this._devices.get(address); + if (entry) + entry.dataHandler = dataHandler; + } + + // Called by orchestrator when a device disconnects/is removed. + removeDevice(address) { + this._devices.delete(address); + this._emitSignal('DeviceRemoved', new GLib.Variant('(s)', [address])); + } + + // Called by orchestrator whenever a DeviceRecord is updated. + emitDeviceChanged(address) { + this._emitSignal('DeviceChanged', new GLib.Variant('(s)', [address])); + } + + _emitSignal(name, params) { + if (!this._connection) + return; + try { + this._connection.emit_signal( + null, OBJECT_PATH, INTERFACE_NAME, name, params + ); + } catch (e) { + console.warn('BBM: failed to emit signal', name, e.message); + } + } + + _handleMethodCall(_conn, _sender, _path, _iface, method, params, invocation) { + try { + switch (method) { + case 'GetDevices': + this._handleGetDevices(invocation); + break; + case 'GetDeviceData': + this._handleGetDeviceData(params.deepUnpack()[0], invocation); + break; + case 'SendUIAction': { + const [address, widgetId, value] = params.deepUnpack(); + this._handleSendUIAction(address, widgetId, value, invocation); + break; + } + case 'GetIconPath': + invocation.return_value(new GLib.Variant('(s)', [this._iconBasePath])); + break; + default: + invocation.return_error_literal( + Gio.DBusError, + Gio.DBusError.UNKNOWN_METHOD, + `Unknown method: ${method}` + ); + } + } catch (e) { + invocation.return_error_literal( + Gio.DBusError, + Gio.DBusError.FAILED, + e.message + ); + } + } + + _handleGetDevices(invocation) { + const addresses = [...this._devices.keys()]; + invocation.return_value(new GLib.Variant('(as)', [addresses])); + } + + _handleGetDeviceData(address, invocation) { + const entry = this._devices.get(address); + if (!entry) { + invocation.return_value(new GLib.Variant('(a{sv})', [{}])); + return; + } + const dict = this._buildDataDict(entry.record); + invocation.return_value(new GLib.Variant('(a{sv})', [dict])); + } + + _handleSendUIAction(address, widgetId, value, invocation) { + const entry = this._devices.get(address); + if (entry?.dataHandler) + entry.dataHandler.emitUIAction(widgetId, value); + invocation.return_value(null); + } + + _buildDataDict(record) { + const v = (type, val) => new GLib.Variant(type, val); + const dict = { + 'Address': v('s', record.address), + 'Alias': v('s', record.alias), + 'Icon': v('s', record.icon ?? ''), + 'Connected': v('b', record.connected), + 'Paired': v('b', record.paired), + 'ComputedLevel': v('i', record.computedLevel), + }; + + for (let i = 0; i < 3; i++) { + const n = i + 1; + const bat = record.batteries[i]; + dict[`Battery${n}Level`] = v('i', bat.level); + dict[`Battery${n}Status`] = v('s', bat.status); + dict[`Battery${n}Icon`] = v('s', bat.icon ?? ''); + } + + for (let t = 0; t < 2; t++) { + const n = t + 1; + const toggle = record.toggles[t]; + dict[`Toggle${n}Title`] = v('s', toggle.title); + dict[`Toggle${n}Buttons`] = v('as', toggle.buttons); + dict[`Toggle${n}ButtonIcons`] = v('as', toggle.buttonIcons); + dict[`Toggle${n}State`] = v('i', toggle.state); + dict[`Toggle${n}Visible`] = v('b', toggle.visible); + } + + dict['AncLevelTitle'] = v('s', record.ancLevel.title); + dict['AncLevelButtons'] = v('as', record.ancLevel.buttons); + dict['AncLevelState'] = v('i', record.ancLevel.state); + dict['OptionsBoxVisible'] = v('i', record.optionsBoxVisible); + + return dict; + } + + destroy() { + if (this._connection && this._registrationId) { + this._connection.unregister_object(this._registrationId); + this._registrationId = null; + } + if (this._busNameId) { + Gio.bus_unown_name(this._busNameId); + this._busNameId = null; + } + this._devices.clear(); + this._connection = null; + } +} diff --git a/bbm/daemon/deviceRecord.js b/bbm/daemon/deviceRecord.js new file mode 100644 index 0000000000..5151e9d07a --- /dev/null +++ b/bbm/daemon/deviceRecord.js @@ -0,0 +1,98 @@ +'use strict'; + +/** + * Creates a blank DeviceRecord — the canonical internal representation of a device. + * The D-Bus service reads only DeviceRecords; it never touches DataHandler or protocol + * objects directly. The orchestrator owns the lifecycle: create on handshake success, + * update on configuration-changed / properties-changed, destroy on disconnect/removal. + */ +export function createDeviceRecord(address, alias, icon, connected, paired) { + return { + // Identity + address, + alias, + icon, + connected, + paired, + + // Computed battery level (0-100), primary level for indicators + computedLevel: 0, + + // Per-component batteries. batteries[0] = primary/left, [1] = right, [2] = case. + batteries: [ + {level: 0, status: 'not-reported', icon: null}, + {level: 0, status: 'not-reported', icon: null}, + {level: 0, status: 'not-reported', icon: null}, + ], + + // Toggle rows (ANC, Conversation Awareness, etc.). + // toggles[0] = toggle1, toggles[1] = toggle2. + toggles: [ + {title: '', buttons: [], buttonIcons: [], state: 0, visible: false}, + {title: '', buttons: [], buttonIcons: [], state: 0, visible: false}, + ], + + // ANC sub-level selector (box1RadioButton / box1RadioButtonState). + // Empty buttons[] means the device has no NC level control. + ancLevel: {title: '', buttons: [], state: 0}, + + // Which options box is currently visible (0 = none, 1 = box1 = NC levels). + // Mirrors props.optionsBoxVisible from DataHandler. + optionsBoxVisible: 0, + }; +} + +/** + * Updates a DeviceRecord in-place from a DataHandler's current config + props. + * Call this whenever configuration-changed or properties-changed fires. + */ +export function updateRecordFromDataHandler(record, dataHandler) { + const cfg = dataHandler.config; + const props = dataHandler.props; + + record.computedLevel = props.computedBatteryLevel; + + record.batteries[0] = { + level: props.battery1Level, + status: props.battery1Status, + icon: cfg.battery1Icon, + }; + record.batteries[1] = { + level: props.battery2Level, + status: props.battery2Status, + icon: cfg.battery2Icon, + }; + record.batteries[2] = { + level: props.battery3Level, + status: props.battery3Status, + icon: cfg.battery3Icon, + }; + + // Build toggle arrays from individual button fields + for (let t = 0; t < 2; t++) { + const n = t + 1; // 1 or 2 + const buttons = []; + const buttonIcons = []; + for (let b = 1; b <= 4; b++) { + const name = cfg[`toggle${n}Button${b}Name`]; + if (name) + buttons.push(name); + buttonIcons.push(cfg[`toggle${n}Button${b}Icon`] ?? ''); + } + record.toggles[t] = { + title: cfg[`toggle${n}Title`] ?? '', + buttons, + buttonIcons: buttonIcons.slice(0, buttons.length), + state: props[`toggle${n}State`], + visible: props[`toggle${n}Visible`], + }; + } + + record.ancLevel = { + title: cfg.box1RadioTitle ?? '', + buttons: Array.isArray(cfg.box1RadioButton) ? [...cfg.box1RadioButton] : [], + state: props.box1RadioButtonState ?? 0, + }; + + record.optionsBoxVisible = props.optionsBoxVisible ?? 0; +} diff --git a/bbm/daemon/main.js b/bbm/daemon/main.js new file mode 100644 index 0000000000..99f8b307ae --- /dev/null +++ b/bbm/daemon/main.js @@ -0,0 +1,43 @@ +#!/usr/bin/env -S gjs -m +'use strict'; +import GLib from 'gi://GLib'; +import GLibUnix from 'gi://GLibUnix'; +import Gio from 'gi://Gio'; + +// Promisify async GIO methods used throughout lib/ (mirrors what extension.js does at startup) +Gio._promisify(Gio.DBusProxy, 'new'); +Gio._promisify(Gio.DBusProxy, 'new_for_bus'); +Gio._promisify(Gio.DBusProxy.prototype, 'call'); +Gio._promisify(Gio.DBusConnection.prototype, 'call'); +Gio._promisify(Gio.InputStream.prototype, 'read_bytes_async'); +Gio._promisify(Gio.OutputStream.prototype, 'write_all_async'); + +import {Orchestrator} from './orchestrator.js'; + +// import.meta.url is a file:// URI in GJS; GLib.filename_from_uri converts it to a path +const daemonDir = GLib.path_get_dirname(GLib.filename_from_uri(import.meta.url)[0]); + +const loop = new GLib.MainLoop(null, false); +const orchestrator = new Orchestrator(daemonDir); + +orchestrator.start().catch(e => { + console.error('BBM Daemon: failed to start:', e.message, e.stack ?? ''); + orchestrator.destroy(); + loop.quit(); +}); + +GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 15 /* SIGTERM */, () => { + console.log('BBM Daemon: shutting down'); + orchestrator.destroy(); + loop.quit(); + return GLib.SOURCE_REMOVE; +}); + +GLibUnix.signal_add(GLib.PRIORITY_DEFAULT, 2 /* SIGINT */, () => { + console.log('BBM Daemon: interrupted'); + orchestrator.destroy(); + loop.quit(); + return GLib.SOURCE_REMOVE; +}); + +loop.run(); diff --git a/bbm/daemon/notifier.js b/bbm/daemon/notifier.js new file mode 100644 index 0000000000..a27a4b2bd2 --- /dev/null +++ b/bbm/daemon/notifier.js @@ -0,0 +1,64 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import {DeviceTypeAirpods} from '../lib/devices/airpods/airpodsDevice.js'; +import {DeviceTypeSonyV1, DeviceTypeSonyV2} from '../lib/devices/sony/sonyDevice.js'; +import { + DeviceTypeGalaxyLegacy, DeviceTypeGalaxyBuds, +} from '../lib/devices/galaxyBuds/galaxyBudsDevice.js'; +import {DeviceTypeNothingBuds} from '../lib/devices/nothingBuds/nothingBudsDevice.js'; +import {DeviceTypeGoogleBuds} from '../lib/devices/googleBuds/googleBudsDevice.js'; +import {DeviceTypeRedmiBuds} from '../lib/devices/redmiBuds/redmiBudsDevice.js'; +import {DeviceTypeSenhBuds} from '../lib/devices/senhBuds/senhBudsDevice.js'; +import {DeviceTypeBoseBuds} from '../lib/devices/boseBuds/boseBudsDevice.js'; +import {DeviceTypeGfps} from '../lib/devices/gfps/gfpsDevice.js'; + +const TYPE_LABELS = { + [DeviceTypeAirpods]: 'AirPods / Beats Bluetooth audio devices', + [DeviceTypeSonyV1]: 'Sony Bluetooth audio devices', + [DeviceTypeSonyV2]: 'Sony Bluetooth audio devices', + [DeviceTypeGalaxyLegacy]: 'Samsung Galaxy Bluetooth audio devices', + [DeviceTypeGalaxyBuds]: 'Samsung Galaxy Bluetooth audio devices', + [DeviceTypeNothingBuds]: 'Nothing / CMF Bluetooth audio devices', + [DeviceTypeGoogleBuds]: 'Google Pixel Buds Bluetooth audio devices', + [DeviceTypeRedmiBuds]: 'Redmi / Xiaomi Bluetooth audio devices', + [DeviceTypeSenhBuds]: 'Sennheiser Bluetooth audio devices', + [DeviceTypeBoseBuds]: 'Bose Bluetooth audio devices', + [DeviceTypeGfps]: 'Google Fast Pair Bluetooth audio devices', +}; + +export function notifyProfileRegisteredError(type) { + const label = TYPE_LABELS[type] ?? type; + const title = `Could not access advanced features for ${label}.`; + const body = + `Another app or session is already using the Bluetooth socket/profile on ${label}. ` + + 'Close any other apps using this device, ' + + 'then disable and re-enable it in BBM settings.'; + + try { + Gio.DBus.session.call( + 'org.freedesktop.Notifications', + '/org/freedesktop/Notifications', + 'org.freedesktop.Notifications', + 'Notify', + new GLib.Variant('(susssasa{sv}i)', [ + 'bbm-daemon', // app_name + 0, // replaces_id + 'bluetooth', // app_icon + title, // summary + body, // body + [], // actions + {}, // hints + 8000, // expire_timeout ms + ]), + null, + Gio.DBusCallFlags.NONE, + -1, + null, + null + ); + } catch (e) { + console.warn('BBM: failed to send notification:', e.message); + } +} diff --git a/bbm/daemon/orchestrator.js b/bbm/daemon/orchestrator.js new file mode 100644 index 0000000000..e07ddf42ff --- /dev/null +++ b/bbm/daemon/orchestrator.js @@ -0,0 +1,238 @@ +'use strict'; +import GLib from 'gi://GLib'; +import Gio from 'gi://Gio'; + +import {createSettings} from './settings.js'; +import {BluezEnumerator} from './bluezEnumerator.js'; +import {DbusService} from './dbusService.js'; +import {notifyProfileRegisteredError} from './notifier.js'; +import {createDeviceRecord, updateRecordFromDataHandler} from './deviceRecord.js'; +import {EnhancedDeviceSupportManager} from '../lib/enhancedDeviceSupportManager.js'; + +/** + * Device lifecycle orchestrator. + * Provides the duck-typed `toggle` interface that EnhancedDeviceSupportManager expects, + * replacing the role played by BluetoothBatteryMeter (bluetoothToggle.js) in the extension. + * + * toggle interface consumed by EnhancedDeviceSupportManager: + * .settings, .extPath + * .airpodsEnabled, .sonyEnabled, .galaxyBudsEnabled, .nothingBudsEnabled, + * .googleBudsEnabled, .redmiBudsEnabled, .senhBudsEnabled, .boseBudsEnabled, + * .gfpsEnabled, .gattBasEnabled + * .notifyCb(type) + * .sync() + */ +export class Orchestrator { + constructor(daemonDir) { + this._daemonDir = daemonDir; + + // extPath is the root of the BBM installation — used by Device.js for icon lookups + this._extPath = GLib.path_get_dirname(daemonDir); + + const schemaDir = GLib.build_filenamev([daemonDir, 'schemas']); + this.settings = createSettings(schemaDir); + + // Expose the duck-typed toggle interface that EnhancedDeviceSupportManager reads + this.extPath = this._extPath; + this._loadEnabledFlags(); + + // Callback for profile registration failures (replaces Notifier) + this.notifyCb = notifyProfileRegisteredError; + + // address → {record, dataHandler, configId, propsId} + this._addressMap = new Map(); + // bluezPath → address (for lookup on removal) + this._pathToAddress = new Map(); + + this._enumerator = null; + this._dbusService = null; + this._enhancedManager = null; + + this._syncRunning = false; + this._syncPending = false; + } + + _loadEnabledFlags() { + this.airpodsEnabled = this.settings.get_boolean('enable-airpods-device'); + this.sonyEnabled = this.settings.get_boolean('enable-sony-device'); + this.galaxyBudsEnabled = this.settings.get_boolean('enable-galaxy-buds-device'); + this.nothingBudsEnabled = this.settings.get_boolean('enable-nothing-buds-device'); + this.googleBudsEnabled = this.settings.get_boolean('enable-google-buds-device'); + this.boseBudsEnabled = this.settings.get_boolean('enable-bose-buds-device'); + this.redmiBudsEnabled = this.settings.get_boolean('enable-redmi-buds-device'); + this.senhBudsEnabled = this.settings.get_boolean('enable-senh-buds-device'); + this.gfpsEnabled = this.settings.get_boolean('enable-gfps-device'); + this.gattBasEnabled = this.settings.get_boolean('enable-gattbas-device'); + } + + async start() { + // D-Bus service acquires org.bbm bus name + const iconBasePath = GLib.build_filenamev([this._extPath, 'icons', 'hicolor', 'scalable', 'actions']); + this._dbusService = new DbusService(iconBasePath); + await this._dbusService.start(); + + // BlueZ enumerator replaces GnomeBluetooth.Client + this._enumerator = new BluezEnumerator(); + this._enumerator.connect('device-added', (_e, path) => this._onDeviceAdded(path)); + this._enumerator.connect('device-removed', (_e, path) => this._onDeviceRemoved(path)); + this._enumerator.connect('device-lifecycle', (_e, path) => this.sync()); + this._enumerator.connect('device-changed', (_e, path) => { + // Metadata-only change: update DeviceRecord if we track this device + const address = this._pathToAddress.get(path); + if (!address) + return; + const entry = this._addressMap.get(address); + if (!entry) + return; + const info = this._enumerator.getDevices().get(path); + if (info) { + entry.record.alias = info.alias; + entry.record.icon = info.icon; + this._dbusService.emitDeviceChanged(address); + } + }); + + // EnhancedDeviceSupportManager receives `this` as the `toggle` duck-type + this._enhancedManager = new EnhancedDeviceSupportManager(this); + + // Initial sync over already-enumerated devices + await this._enumerator.start(); + this.sync(); + + console.log('BBM Daemon: started'); + } + + // Called by EnhancedDeviceSupportManager and BluezEnumerator on lifecycle events. + sync() { + // Guard against calls that arrive after destroy() (idle callbacks, in-flight BlueZ signals). + if (!this._enhancedManager || !this._enumerator) + return; + if (this._syncRunning) { + this._syncPending = true; + return; + } + this._syncRunning = true; + this._syncPending = false; + + for (const [path, info] of this._enumerator.getDevices()) { + const result = this._enhancedManager.onDeviceSync( + path, info.connected, info.icon, info.alias + ); + // Always call updateDeviceMapCb when a dataHandler is available — it handles + // both first-connect (creates record) and reconnect (rewires to fresh handler). + if (result?.dataHandler) + this.updateDeviceMapCb(path, result.dataHandler); + } + this._enhancedManager.updateEnhancedDevicesInstance(); + + this._syncRunning = false; + if (this._syncPending) + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { this.sync(); return GLib.SOURCE_REMOVE; }); + } + + /** + * Called whenever EnhancedDeviceSupportManager has a live DataHandler for a device. + * Handles both first-connect (creates DeviceRecord, announces DeviceAdded) and + * reconnect (rewires signal handlers to the fresh DataHandler so live data flows again). + */ + updateDeviceMapCb(path, dataHandler) { + const existingAddress = this._pathToAddress.get(path); + + if (existingAddress) { + // Device already known — check if the DataHandler changed (reconnect case). + const entry = this._addressMap.get(existingAddress); + if (!entry || entry.dataHandler === dataHandler) + return; // Same handler or entry missing — nothing to do. + + // Disconnect stale signals from the dead handler and rewire to the fresh one. + entry.dataHandler.disconnect(entry.configId); + entry.dataHandler.disconnect(entry.propsId); + + updateRecordFromDataHandler(entry.record, dataHandler); + + const configId = dataHandler.connect('configuration-changed', () => { + updateRecordFromDataHandler(entry.record, dataHandler); + this._dbusService.emitDeviceChanged(existingAddress); + }); + const propsId = dataHandler.connect('properties-changed', () => { + updateRecordFromDataHandler(entry.record, dataHandler); + this._dbusService.emitDeviceChanged(existingAddress); + }); + + this._addressMap.set(existingAddress, {record: entry.record, dataHandler, configId, propsId}); + this._dbusService.updateDeviceHandler(existingAddress, dataHandler); + this._dbusService.emitDeviceChanged(existingAddress); + return; + } + + // First time seeing this device — create DeviceRecord and announce to D-Bus clients. + const info = this._enumerator.getDevices().get(path); + if (!info) { + console.warn('BBM: updateDeviceMapCb called for unknown path:', path); + return; + } + + const {address, alias, icon, connected, paired} = info; + + const record = createDeviceRecord(address, alias, icon, connected, paired); + updateRecordFromDataHandler(record, dataHandler); + + const configId = dataHandler.connect('configuration-changed', () => { + updateRecordFromDataHandler(record, dataHandler); + this._dbusService.emitDeviceChanged(address); + }); + const propsId = dataHandler.connect('properties-changed', () => { + updateRecordFromDataHandler(record, dataHandler); + this._dbusService.emitDeviceChanged(address); + }); + + this._addressMap.set(address, {record, dataHandler, configId, propsId}); + this._pathToAddress.set(path, address); + + this._dbusService.addDevice(address, record, dataHandler); + + // Run another sync so any pending devices are also processed. + this.sync(); + } + + _onDeviceAdded(path) { + this.sync(); + } + + _onDeviceRemoved(path) { + const address = this._pathToAddress.get(path); + if (address) + this._cleanupDevice(address, path); + this.sync(); + } + + _cleanupDevice(address, path) { + const entry = this._addressMap.get(address); + if (entry) { + if (entry.dataHandler) { + entry.dataHandler.disconnect(entry.configId); + entry.dataHandler.disconnect(entry.propsId); + } + this._addressMap.delete(address); + } + this._pathToAddress.delete(path); + this._dbusService.removeDevice(address); + } + + destroy() { + if (this._enhancedManager) { + this._enhancedManager.destroy(); + this._enhancedManager = null; + } + if (this._enumerator) { + this._enumerator.destroy(); + this._enumerator = null; + } + if (this._dbusService) { + this._dbusService.destroy(); + this._dbusService = null; + } + this._addressMap.clear(); + this._pathToAddress.clear(); + } +} diff --git a/bbm/daemon/schemas/org.bbm.gschema.xml b/bbm/daemon/schemas/org.bbm.gschema.xml new file mode 100644 index 0000000000..cabcc43987 --- /dev/null +++ b/bbm/daemon/schemas/org.bbm.gschema.xml @@ -0,0 +1,75 @@ + + + + + + + true + + + true + + + true + + + true + + + true + + + true + + + true + + + true + + + true + + + true + + + + + [] + + + [] + + + [] + + + [] + + + [] + + + [] + + + [] + + + [] + + + [] + + + [] + + + + + [] + + + + diff --git a/bbm/daemon/settings.js b/bbm/daemon/settings.js new file mode 100644 index 0000000000..25b396fbe9 --- /dev/null +++ b/bbm/daemon/settings.js @@ -0,0 +1,167 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +/** + * Tries to load the compiled org.bbm GSettings schema from schemaDir. + * Falls back to JsonSettings backed by ~/.config/bbm/settings.json. + */ +export function createSettings(schemaDir) { + try { + const source = Gio.SettingsSchemaSource.new_from_directory( + schemaDir, + Gio.SettingsSchemaSource.get_default(), + false + ); + const schema = source.lookup('org.bbm', false); + if (!schema) + throw new Error('Schema org.bbm not found in ' + schemaDir); + return new Gio.Settings({settings_schema: schema}); + } catch (e) { + console.warn('BBM: falling back to JsonSettings:', e.message); + return new JsonSettings(); + } +} + +// --------------------------------------------------------------------------- +// JsonSettings — minimal Gio.Settings-compatible shim backed by a JSON file. +// Implements only the surface area that *Device.js files call. +// --------------------------------------------------------------------------- + +const DEFAULTS = { + 'enable-airpods-device': true, + 'enable-sony-device': true, + 'enable-galaxy-buds-device': true, + 'enable-nothing-buds-device': true, + 'enable-google-buds-device': true, + 'enable-bose-buds-device': true, + 'enable-redmi-buds-device': true, + 'enable-senh-buds-device': true, + 'enable-gfps-device': true, + 'enable-gattbas-device': true, + 'airpods-list': [], + 'sony-list': [], + 'galaxy-buds-list': [], + 'nothing-buds-list': [], + 'google-buds-list': [], + 'bose-buds-list': [], + 'redmi-buds-list': [], + 'senh-buds-list': [], + 'gfps-list': [], + 'gattbas-list': [], + 'attenuated-on-destroy-info': [], +}; + +class JsonSettings { + constructor() { + this._path = GLib.build_filenamev([ + GLib.get_user_config_dir(), 'bbm', 'settings.json', + ]); + this._data = {...DEFAULTS}; + this._signals = new Map(); // key -> [{id, cb}] + this._nextId = 1; + this._ownerMap = new Map(); // owner -> [id] + this._load(); + } + + _load() { + try { + const file = Gio.File.new_for_path(this._path); + const [ok, contents] = file.load_contents(null); + if (ok) { + const parsed = JSON.parse(new TextDecoder().decode(contents)); + Object.assign(this._data, parsed); + } + } catch { + // File doesn't exist yet — use defaults + } + } + + _save() { + try { + const dir = Gio.File.new_for_path(GLib.path_get_dirname(this._path)); + dir.make_directory_with_parents(null); + } catch { + // Already exists + } + try { + const file = Gio.File.new_for_path(this._path); + const bytes = new TextEncoder().encode(JSON.stringify(this._data, null, 2)); + file.replace_contents(bytes, null, false, + Gio.FileCreateFlags.REPLACE_DESTINATION, null); + } catch (e) { + console.warn('BBM: failed to save settings:', e.message); + } + } + + _emit(key) { + const listeners = this._signals.get(key) ?? []; + for (const {cb} of listeners) + cb(this, key); + } + + get_boolean(key) { + return this._data[key] ?? DEFAULTS[key] ?? false; + } + + get_int(key) { + return this._data[key] ?? DEFAULTS[key] ?? 0; + } + + get_strv(key) { + return this._data[key] ?? DEFAULTS[key] ?? []; + } + + set_boolean(key, value) { + this._data[key] = value; + this._save(); + this._emit(key); + } + + set_strv(key, value) { + this._data[key] = value; + this._save(); + this._emit(key); + } + + connect(signal, cb) { + // signal is 'changed::key-name' + const key = signal.startsWith('changed::') ? signal.slice(9) : signal; + if (!this._signals.has(key)) + this._signals.set(key, []); + const id = this._nextId++; + this._signals.get(key).push({id, cb}); + return id; + } + + disconnect(id) { + for (const [key, list] of this._signals) { + const idx = list.findIndex(e => e.id === id); + if (idx !== -1) { + list.splice(idx, 1); + return; + } + } + } + + // connectObject(sig1, cb1, sig2, cb2, ..., owner) — GObject-style multi-connect + connectObject(...args) { + const owner = args[args.length - 1]; + const pairs = args.slice(0, -1); + const ids = []; + for (let i = 0; i < pairs.length; i += 2) { + const id = this.connect(pairs[i], pairs[i + 1]); + ids.push(id); + } + if (!this._ownerMap.has(owner)) + this._ownerMap.set(owner, []); + this._ownerMap.get(owner).push(...ids); + } + + disconnectObject(owner) { + const ids = this._ownerMap.get(owner) ?? []; + for (const id of ids) + this.disconnect(id); + this._ownerMap.delete(owner); + } +} diff --git a/bbm/icons/hicolor/scalable/actions/bbm-adaptive-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-adaptive-symbolic.svg new file mode 100644 index 0000000000..e22d9d95b0 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-adaptive-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-add-toggle-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-add-toggle-symbolic.svg new file mode 100644 index 0000000000..6f1bc0eabb --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-add-toggle-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-added-toggle-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-added-toggle-symbolic.svg new file mode 100644 index 0000000000..44b0624998 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-added-toggle-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-anc-off-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-anc-off-symbolic.svg new file mode 100644 index 0000000000..3409364f1a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-anc-off-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-anc-on-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-anc-on-symbolic.svg new file mode 100644 index 0000000000..7d487a708c --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-anc-on-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-anc-wind-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-anc-wind-symbolic.svg new file mode 100644 index 0000000000..8440a5b8ed --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-anc-wind-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-card.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-card.png new file mode 100644 index 0000000000..6e1c289e2d Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-card.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headphones.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headphones.png new file mode 100644 index 0000000000..719a746142 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headphones.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headset.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headset.png new file mode 100644 index 0000000000..3284e9cb9f Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-headset.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers.png new file mode 100644 index 0000000000..487d5f3537 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers2.png new file mode 100644 index 0000000000..5e244278d7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers3.png b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers3.png new file mode 100644 index 0000000000..5e244278d7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers3.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-bluetooth.png b/bbm/icons/hicolor/scalable/actions/bbm-art-bluetooth.png new file mode 100644 index 0000000000..155295309d Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-bluetooth.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-camera-photo.png b/bbm/icons/hicolor/scalable/actions/bbm-art-camera-photo.png new file mode 100644 index 0000000000..6ab047991c Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-camera-photo.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-camera-video.png b/bbm/icons/hicolor/scalable/actions/bbm-art-camera-video.png new file mode 100644 index 0000000000..9d00ea38ae Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-camera-video.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-computer.png b/bbm/icons/hicolor/scalable/actions/bbm-art-computer.png new file mode 100644 index 0000000000..45788f47ba Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-computer.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-bean.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-bean.png new file mode 100644 index 0000000000..c1d398b351 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-bean.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-neckband.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-neckband.png new file mode 100644 index 0000000000..e604addaa1 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-neckband.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem.png new file mode 100644 index 0000000000..67edc650ee Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem2.png new file mode 100644 index 0000000000..b71ed9528a Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem3.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem3.png new file mode 100644 index 0000000000..560e201d61 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem3.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip.png new file mode 100644 index 0000000000..0261a8531e Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip2.png new file mode 100644 index 0000000000..563c947760 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds.png b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds.png new file mode 100644 index 0000000000..33534aaee7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-earbuds.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-headphone1.png b/bbm/icons/hicolor/scalable/actions/bbm-art-headphone1.png new file mode 100644 index 0000000000..719a746142 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-headphone1.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming.png new file mode 100644 index 0000000000..1c0be7c2b7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming2.png new file mode 100644 index 0000000000..1c0be7c2b7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming3.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming3.png new file mode 100644 index 0000000000..1c0be7c2b7 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming3.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard.png new file mode 100644 index 0000000000..7831fced69 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard2.png new file mode 100644 index 0000000000..7831fced69 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-microphone.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-microphone.png new file mode 100644 index 0000000000..85384170f6 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-microphone.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-mouse.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-mouse.png new file mode 100644 index 0000000000..de60d1993b Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-mouse.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-split-keyboard.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-split-keyboard.png new file mode 100644 index 0000000000..94e9cb4440 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-split-keyboard.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-input-tablet.png b/bbm/icons/hicolor/scalable/actions/bbm-art-input-tablet.png new file mode 100644 index 0000000000..877dca6441 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-input-tablet.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-modem.png b/bbm/icons/hicolor/scalable/actions/bbm-art-modem.png new file mode 100644 index 0000000000..e1478e3d3f Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-modem.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-multimedia-player.png b/bbm/icons/hicolor/scalable/actions/bbm-art-multimedia-player.png new file mode 100644 index 0000000000..737f6d9ccf Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-multimedia-player.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-network-wireless.png b/bbm/icons/hicolor/scalable/actions/bbm-art-network-wireless.png new file mode 100644 index 0000000000..ca0980603f Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-network-wireless.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-pda.png b/bbm/icons/hicolor/scalable/actions/bbm-art-pda.png new file mode 100644 index 0000000000..e6fcec4a03 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-pda.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-phone-apple-iphone.png b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-apple-iphone.png new file mode 100644 index 0000000000..d92601c3a1 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-apple-iphone.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-phone-google-nexus-one.png b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-google-nexus-one.png new file mode 100644 index 0000000000..d92601c3a1 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-google-nexus-one.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-phone-samsung-galaxy-s.png b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-samsung-galaxy-s.png new file mode 100644 index 0000000000..d92601c3a1 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-phone-samsung-galaxy-s.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-phone.png b/bbm/icons/hicolor/scalable/actions/bbm-art-phone.png new file mode 100644 index 0000000000..d92601c3a1 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-phone.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-printer.png b/bbm/icons/hicolor/scalable/actions/bbm-art-printer.png new file mode 100644 index 0000000000..562c15de32 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-printer.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-scanner.png b/bbm/icons/hicolor/scalable/actions/bbm-art-scanner.png new file mode 100644 index 0000000000..340be0f1b8 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-scanner.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-touchpad.png b/bbm/icons/hicolor/scalable/actions/bbm-art-touchpad.png new file mode 100644 index 0000000000..f8b536d5cc Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-touchpad.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-upower-devices.png b/bbm/icons/hicolor/scalable/actions/bbm-art-upower-devices.png new file mode 100644 index 0000000000..ca38bb2b6c Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-upower-devices.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-video-display.png b/bbm/icons/hicolor/scalable/actions/bbm-art-video-display.png new file mode 100644 index 0000000000..d417813ef9 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-video-display.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-wearable.png b/bbm/icons/hicolor/scalable/actions/bbm-art-wearable.png new file mode 100644 index 0000000000..5aada4d538 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-wearable.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-art-wearable2.png b/bbm/icons/hicolor/scalable/actions/bbm-art-wearable2.png new file mode 100644 index 0000000000..00c9b56981 Binary files /dev/null and b/bbm/icons/hicolor/scalable/actions/bbm-art-wearable2.png differ diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-card-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-card-symbolic.svg new file mode 100644 index 0000000000..1a3c51a374 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-card-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-headphones-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-headphones-symbolic.svg new file mode 100644 index 0000000000..c2454380e7 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-headphones-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-headset-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-headset-symbolic.svg new file mode 100644 index 0000000000..8a701fd116 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-headset-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers-symbolic.svg new file mode 100644 index 0000000000..00749be7a2 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers2-symbolic.svg new file mode 100644 index 0000000000..2bbf5a9b7b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers2-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers3-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers3-symbolic.svg new file mode 100644 index 0000000000..09d3e3fd66 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-audio-speakers3-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-battery-widget-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-battery-widget-symbolic.svg new file mode 100644 index 0000000000..0fd9be3844 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-battery-widget-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bell-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bell-symbolic.svg new file mode 100644 index 0000000000..6a83b9e70a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bell-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connected-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connected-symbolic.svg new file mode 100644 index 0000000000..fdaa411988 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connected-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-1-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-1-symbolic.svg new file mode 100644 index 0000000000..6d19781b3c --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-1-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-2-symbolic.svg new file mode 100644 index 0000000000..196cc83c63 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-2-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-3-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-3-symbolic.svg new file mode 100644 index 0000000000..f495b155e7 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-3-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-4-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-4-symbolic.svg new file mode 100644 index 0000000000..35bf47b14a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-4-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-1-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-1-symbolic.svg new file mode 100644 index 0000000000..d3015c0aee --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-1-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-2-symbolic.svg new file mode 100644 index 0000000000..7136e61217 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-3-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-3-symbolic.svg new file mode 100644 index 0000000000..ae4c5deaa5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-3-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-4-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-4-symbolic.svg new file mode 100644 index 0000000000..f4c7ae0549 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-4-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-gui-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-gui-symbolic.svg new file mode 100644 index 0000000000..1f072d8dc3 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-gui-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-symbolic.svg new file mode 100644 index 0000000000..30fef2c847 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bluetooth-symbolic.svg @@ -0,0 +1,3 @@ + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bug-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bug-symbolic.svg new file mode 100644 index 0000000000..cb6f092e02 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bug-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-bypass-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-bypass-symbolic.svg new file mode 100644 index 0000000000..6cb26e9b7a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-bypass-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-ca-off-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-ca-off-symbolic.svg new file mode 100644 index 0000000000..59fd409778 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-ca-off-symbolic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-ca-on-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-ca-on-symbolic.svg new file mode 100644 index 0000000000..a881d3398b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-ca-on-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-camera-photo-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-camera-photo-symbolic.svg new file mode 100644 index 0000000000..7b495cd2c4 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-camera-photo-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-camera-video-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-camera-video-symbolic.svg new file mode 100644 index 0000000000..ab9f1a7efa --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-camera-video-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-narrow-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-narrow-symbolic.svg new file mode 100644 index 0000000000..f2e4bc5bd6 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-narrow-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-normal-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-normal-symbolic.svg new file mode 100644 index 0000000000..7cc232b2c5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-normal-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-oval-short-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-oval-short-symbolic.svg new file mode 100644 index 0000000000..b488323c87 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-oval-short-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-oval-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-oval-symbolic.svg new file mode 100644 index 0000000000..70cf8daa54 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-oval-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-round-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-round-symbolic.svg new file mode 100644 index 0000000000..92dd682340 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-round-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-case-square-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-case-square-symbolic.svg new file mode 100644 index 0000000000..e6c84ba5a5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-case-square-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-check-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-check-symbolic.svg new file mode 100644 index 0000000000..3bd89787d4 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-check-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-color-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-color-symbolic.svg new file mode 100644 index 0000000000..dad176daac --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-color-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-computer-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-computer-symbolic.svg new file mode 100644 index 0000000000..bfea1eb8c6 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-computer-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-dark-mode-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-dark-mode-symbolic.svg new file mode 100644 index 0000000000..a8c9392190 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-dark-mode-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-db-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-db-symbolic.svg new file mode 100644 index 0000000000..2b11c1843d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-db-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-device-mgmt-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-device-mgmt-symbolic.svg new file mode 100644 index 0000000000..6ca310e537 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-device-mgmt-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-left-symbolic.svg new file mode 100644 index 0000000000..af7b77dcfc --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-right-symbolic.svg new file mode 100644 index 0000000000..69f8815790 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-symbolic.svg new file mode 100644 index 0000000000..4b2d027d9d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-left-symbolic.svg new file mode 100644 index 0000000000..48d5df750d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-neckband-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-neckband-symbolic.svg new file mode 100644 index 0000000000..2504a9d144 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-neckband-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-right-symbolic.svg new file mode 100644 index 0000000000..8751827074 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-left-symbolic.svg new file mode 100644 index 0000000000..7a49c90d0b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-right-symbolic.svg new file mode 100644 index 0000000000..2eb1b1f32d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-symbolic.svg new file mode 100644 index 0000000000..53cae7ba48 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-left-symbolic.svg new file mode 100644 index 0000000000..4c6b84285d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-right-symbolic.svg new file mode 100644 index 0000000000..df9f821fab --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-symbolic.svg new file mode 100644 index 0000000000..cd1aa47c74 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-left-symbolic.svg new file mode 100644 index 0000000000..cde0288284 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-right-symbolic.svg new file mode 100644 index 0000000000..91fdc15156 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-symbolic.svg new file mode 100644 index 0000000000..64a08d9dc2 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-symbolic.svg new file mode 100644 index 0000000000..5ea0143b38 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-left-symbolic.svg new file mode 100644 index 0000000000..066855829b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-left-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-right-symbolic.svg new file mode 100644 index 0000000000..f6f7651735 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-right-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-symbolic.svg new file mode 100644 index 0000000000..8265005fcb --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-left-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-left-symbolic.svg new file mode 100644 index 0000000000..e7d1130a6f --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-left-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-right-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-right-symbolic.svg new file mode 100644 index 0000000000..b65e9890e8 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-right-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-symbolic.svg new file mode 100644 index 0000000000..e35a0ee840 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-edit-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-edit-symbolic.svg new file mode 100644 index 0000000000..11b7dda35d --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-edit-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-enhanced-device-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-enhanced-device-symbolic.svg new file mode 100644 index 0000000000..b8960b9e93 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-enhanced-device-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-eq-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-eq-symbolic.svg new file mode 100644 index 0000000000..f1daccb46a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-eq-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-extension-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-extension-symbolic.svg new file mode 100644 index 0000000000..dfcef41473 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-extension-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-gatt-bas-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-gatt-bas-symbolic.svg new file mode 100644 index 0000000000..6b3165de72 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-gatt-bas-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-github-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-github-symbolic.svg new file mode 100644 index 0000000000..d225066d3b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-github-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-headphone1-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-headphone1-symbolic.svg new file mode 100644 index 0000000000..9a9a7be335 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-headphone1-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-help-about-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-help-about-symbolic.svg new file mode 100644 index 0000000000..65594256ef --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-help-about-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-hpf-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-hpf-symbolic.svg new file mode 100644 index 0000000000..7cb615e5f5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-hpf-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-hsf-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-hsf-symbolic.svg new file mode 100644 index 0000000000..48f88f5335 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-hsf-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-hz-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-hz-symbolic.svg new file mode 100644 index 0000000000..fc0c9c60be --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-hz-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-gaming-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming-symbolic.svg new file mode 100644 index 0000000000..6e3a134058 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-gaming2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming2-symbolic.svg new file mode 100644 index 0000000000..2f1d415ac7 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming2-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-gaming3-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming3-symbolic.svg new file mode 100644 index 0000000000..599ba966f5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-gaming3-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard-symbolic.svg new file mode 100644 index 0000000000..632c386b4a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard2-symbolic.svg new file mode 100644 index 0000000000..1c9095e903 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-keyboard2-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-microphone-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-microphone-symbolic.svg new file mode 100644 index 0000000000..9fd553d458 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-microphone-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-mouse-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-mouse-symbolic.svg new file mode 100644 index 0000000000..ad05af0e3f --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-mouse-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-l-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-l-symbolic.svg new file mode 100644 index 0000000000..817709e38b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-l-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-r-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-r-symbolic.svg new file mode 100644 index 0000000000..7204ba9e80 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-r-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-symbolic.svg new file mode 100644 index 0000000000..818944446b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-input-tablet-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-input-tablet-symbolic.svg new file mode 100644 index 0000000000..2bbb0b1230 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-input-tablet-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-legal-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-legal-symbolic.svg new file mode 100644 index 0000000000..f4e2a3782c --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-legal-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-logo-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-logo-symbolic.svg new file mode 100644 index 0000000000..a5478c57d0 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-logo-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-logo.svg b/bbm/icons/hicolor/scalable/actions/bbm-logo.svg new file mode 100644 index 0000000000..e1ade7041b --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-logs-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-logs-symbolic.svg new file mode 100644 index 0000000000..8855079b6f --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-logs-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-lpf-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-lpf-symbolic.svg new file mode 100644 index 0000000000..c63da27d3a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-lpf-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-lsf-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-lsf-symbolic.svg new file mode 100644 index 0000000000..57f3940225 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-lsf-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-aware-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-aware-symbolic.svg new file mode 100644 index 0000000000..c836fd6eb9 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-aware-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-commute-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-commute-symbolic.svg new file mode 100644 index 0000000000..f5aeb67288 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-commute-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-focus-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-focus-symbolic.svg new file mode 100644 index 0000000000..ab0f341dbb --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-focus-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-home-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-home-symbolic.svg new file mode 100644 index 0000000000..e5cc3933e1 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-home-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-immersion-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-immersion-symbolic.svg new file mode 100644 index 0000000000..b597e5f101 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-immersion-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-music-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-music-symbolic.svg new file mode 100644 index 0000000000..de14ed21e5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-music-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-outdoor-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-outdoor-symbolic.svg new file mode 100644 index 0000000000..f6f4048581 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-outdoor-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-quiet-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-quiet-symbolic.svg new file mode 100644 index 0000000000..463f5ad692 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-quiet-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-relax-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-relax-symbolic.svg new file mode 100644 index 0000000000..7407a6ba58 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-relax-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-run-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-run-symbolic.svg new file mode 100644 index 0000000000..7d7ad5dea9 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-run-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-walk-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-walk-symbolic.svg new file mode 100644 index 0000000000..a96dd0b38c --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-walk-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-work-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-work-symbolic.svg new file mode 100644 index 0000000000..4c88e9c87f --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-work-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-mode-workout-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-mode-workout-symbolic.svg new file mode 100644 index 0000000000..dfa88067e5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-mode-workout-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-modem-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-modem-symbolic.svg new file mode 100644 index 0000000000..95b68e47a8 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-modem-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-multimedia-player-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-multimedia-player-symbolic.svg new file mode 100644 index 0000000000..d08803b4e4 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-multimedia-player-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-network-wireless-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-network-wireless-symbolic.svg new file mode 100644 index 0000000000..52e402334a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-network-wireless-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-open-menu-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-open-menu-symbolic.svg new file mode 100644 index 0000000000..facf1fbaf7 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-open-menu-symbolic.svg @@ -0,0 +1,2 @@ + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-panel-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-panel-symbolic.svg new file mode 100644 index 0000000000..710b49eb33 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-panel-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-pda-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-pda-symbolic.svg new file mode 100644 index 0000000000..a791834d27 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-pda-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-peq-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-peq-symbolic.svg new file mode 100644 index 0000000000..e55731fb31 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-peq-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-phone-apple-iphone-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-phone-apple-iphone-symbolic.svg new file mode 100644 index 0000000000..feda867ed1 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-phone-apple-iphone-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-phone-google-nexus-one-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-phone-google-nexus-one-symbolic.svg new file mode 100644 index 0000000000..9e52903922 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-phone-google-nexus-one-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-phone-samsung-galaxy-s-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-phone-samsung-galaxy-s-symbolic.svg new file mode 100644 index 0000000000..05e77518df --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-phone-samsung-galaxy-s-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-phone-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-phone-symbolic.svg new file mode 100644 index 0000000000..4deb9c3810 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-phone-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-pin-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-pin-symbolic.svg new file mode 100644 index 0000000000..589c54260a --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-pin-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-pinned-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-pinned-symbolic.svg new file mode 100644 index 0000000000..76bd4b49bd --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-pinned-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-play-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-play-symbolic.svg new file mode 100644 index 0000000000..61465cf7f5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-play-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-preamp-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-preamp-symbolic.svg new file mode 100644 index 0000000000..7e8e325fe0 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-preamp-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-printer-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-printer-symbolic.svg new file mode 100644 index 0000000000..fbd5394ac5 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-printer-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-q-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-q-symbolic.svg new file mode 100644 index 0000000000..2906e4c68e --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-q-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-quick-settings-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-quick-settings-symbolic.svg new file mode 100644 index 0000000000..a4e7997559 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-quick-settings-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-radio-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-radio-symbolic.svg new file mode 100644 index 0000000000..72db70f401 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-radio-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-readme-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-readme-symbolic.svg new file mode 100644 index 0000000000..5227d53169 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-readme-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-scanner-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-scanner-symbolic.svg new file mode 100644 index 0000000000..a7977d27dc --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-scanner-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-settings-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-settings-symbolic.svg new file mode 100644 index 0000000000..bf407a8b61 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-settings-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-spatial-motion-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-spatial-motion-symbolic.svg new file mode 100644 index 0000000000..55f7c96833 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-spatial-motion-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-spatial-off-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-spatial-off-symbolic.svg new file mode 100644 index 0000000000..89d0b1ea46 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-spatial-off-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-spatial-still-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-spatial-still-symbolic.svg new file mode 100644 index 0000000000..b597e5f101 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-spatial-still-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-speakers-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-speakers-symbolic.svg new file mode 100644 index 0000000000..25a9192307 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-speakers-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-star-large-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-star-large-symbolic.svg new file mode 100644 index 0000000000..5e6125f032 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-star-large-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-star-outline-rounded-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-star-outline-rounded-symbolic.svg new file mode 100644 index 0000000000..242fb47849 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-star-outline-rounded-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-stop-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-stop-symbolic.svg new file mode 100644 index 0000000000..11168de475 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-stop-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-touchpad-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-touchpad-symbolic.svg new file mode 100644 index 0000000000..04520beea0 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-touchpad-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-translation-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-translation-symbolic.svg new file mode 100644 index 0000000000..5067fcc385 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-translation-symbolic.svg @@ -0,0 +1 @@ + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-transperancy-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-transperancy-symbolic.svg new file mode 100644 index 0000000000..23427f4020 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-transperancy-symbolic.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bbm/icons/hicolor/scalable/actions/bbm-upower-devices-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-upower-devices-symbolic.svg new file mode 100644 index 0000000000..1b2adc0537 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-upower-devices-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-video-display-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-video-display-symbolic.svg new file mode 100644 index 0000000000..c5369638a4 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-video-display-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-wearable-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-wearable-symbolic.svg new file mode 100644 index 0000000000..ae7acfbfd3 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-wearable-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/icons/hicolor/scalable/actions/bbm-wearable2-symbolic.svg b/bbm/icons/hicolor/scalable/actions/bbm-wearable2-symbolic.svg new file mode 100644 index 0000000000..e8dc2739c1 --- /dev/null +++ b/bbm/icons/hicolor/scalable/actions/bbm-wearable2-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/bbm/lib/bluetoothToggle.js b/bbm/lib/bluetoothToggle.js new file mode 100644 index 0000000000..41790db5ea --- /dev/null +++ b/bbm/lib/bluetoothToggle.js @@ -0,0 +1,740 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {ngettext} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {EnhancedDeviceSupportManager} from './enhancedDeviceSupportManager.js'; +import {WidgetManagerBluez} from './widgetManagerBluez.js'; +import {WidgetManagerEnhanced} from './widgetManagerEnhanced.js'; +import {PanelButtonSingleDevice} from './panelButtonSingleDevice.js'; +import {PanelButtonMultiDevice} from './panelButtonMultiDevice.js'; +import {UpowerClient} from './upower/upowerDevice.js'; +import {supportedIcons} from './widgets/iconGroups.js'; +import { + isDarkMode, adjustColorLuminanceToRgba, getAccentColor, hexToColor +} from './widgets/colorHelpers.js'; + +const QuickSettingsMenu = Main.panel.statusArea.quickSettings; + +export const BluetoothBatteryMeter = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BluetoothToggle', +}, class BluetoothBatteryMeter extends GObject.Object { + _init(settings, extensionPath, extuuid) { + super._init(); + this.settings = settings; + this.extPath = extensionPath; + this._extuuid = extuuid; + + this._idleTimerId = GLib.idle_add(GLib.PRIORITY_LOW, () => { + if (!Main.panel.statusArea.quickSettings._bluetooth && + Main.panel.statusArea.quickSettings._bluetooth.quickSettingsItems[0] + ._box.get_first_child().get_stage()) + return GLib.SOURCE_CONTINUE; + + this._bluetoothToggle = Main.panel.statusArea.quickSettings + ._bluetooth.quickSettingsItems[0]; + this._startBluetoothToggle(); + this._idleTimerId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _startBluetoothToggle() { + this._syncRunning = false; + this._syncPending = false; + this._deviceItems = new Map(); + this.deviceList = new Map(); + this.connectedColor = '#8fbbf0'; + this._indicatorBox = null; + this._pullDevicesFromGsetting(); + this.gIcon = iconName => Gio.icon_new_for_string( + `${this.extPath}/icons/hicolor/scalable/actions/${iconName}`); + this.modifyQuickSettings = this.settings.get_boolean('modify-quick-settings'); + this.usePopupInQuickSettings = this.settings.get_boolean('popup-in-quick-settings'); + this.showBatteryPercentage = this.settings.get_boolean('enable-battery-level-text'); + this.showBatteryIcon = this.settings.get_boolean('enable-battery-level-icon'); + this.swapIconText = this.settings.get_boolean('swap-icon-text'); + this._sortDevicesByHistory = this.settings.get_boolean('sort-devices-by-history'); + this._hideBluetoothIndicator = this.settings.get_int('hide-bluetooth-indicator'); + this._indicatorType = this.settings.get_int('indicator-type'); + this._panelSingleIndicator = this.settings.get_boolean('panel-button-single-indicator'); + this._enableMultimodeIndicator = this.settings.get_boolean('enable-multi-indicator-mode'); + this._enableHoverMode = this.settings.get_boolean('enable-on-hover-mode'); + + this.widgetInfo = { + extPath: this.extPath, + isUnlockSession: Main.sessionMode.currentMode === 'unlock-dialog', + disableLevelInIcon: this.settings.get_boolean('disable-level-in-icon'), + indicatorWithText: this.settings.get_boolean('enable-battery-indicator-text'), + levelIndicatorType: this.settings.get_int('level-indicator-type'), + levelBarPosition: this.settings.get_int('level-bar-position'), + levelIndicatorColor: this.settings.get_int('level-indicator-color'), + levelIndicatorCustomColors: this.settings.get_strv('level-indicator-custom-colors'), + circleWidgetColor: this.settings.get_int('circle-widget-color'), + circleWidgetCustomColors: this.settings.get_strv('circle-widget-custom-colors'), + accentColor: hexToColor('#3584e4'), + }; + + this.airpodsEnabled = this.settings.get_boolean('enable-airpods-device'); + this.sonyEnabled = this.settings.get_boolean('enable-sony-device'); + this.galaxyBudsEnabled = this.settings.get_boolean('enable-galaxy-buds-device'); + this.nothingBudsEnabled = this.settings.get_boolean('enable-nothing-buds-device'); + this.googleBudsEnabled = this.settings.get_boolean('enable-google-buds-device'); + this.boseBudsEnabled = this.settings.get_boolean('enable-bose-buds-device'); + this.redmiBudsEnabled = this.settings.get_boolean('enable-redmi-buds-device'); + this.senhBudsEnabled = this.settings.get_boolean('enable-senh-buds-device'); + this.gfpsEnabled = this.settings.get_boolean('enable-gfps-device'); + this.gattBasEnabled = this.settings.get_boolean('enable-gattbas-device'); + + this._updateColors(); + + this._originalSync = this._bluetoothToggle._sync; + this._bluetoothToggle._sync = () => {}; + this._bluetoothToggle._sync = () => { + this.sync(); + }; + this._originalRemoveDevice = this._bluetoothToggle._removeDevice; + this._bluetoothToggle._removeDevice = path => { + this._removeDevice(path); + }; + this._originalOnActiveChanged = this._bluetoothToggle._onActiveChanged; + this._bluetoothToggle._onActiveChanged = () => { + this._onActiveChanged(); + }; + this._originalReorderDeviceItems = this._bluetoothToggle._reorderDeviceItems; + this._bluetoothToggle._reorderDeviceItems = () => { + this._reorderDeviceItems(); + }; + + this._originalBluetoothIndicatorSync = QuickSettingsMenu._bluetooth._sync; + QuickSettingsMenu._bluetooth._sync = () => {}; + + this._bluetoothToggle._deviceItems.forEach(item => { + item.destroy(); + }); + this._bluetoothToggle._deviceItems.clear(); + + this._themeContext = St.ThemeContext.get_for_stage(global.stage); + this._themeContext.connectObject('changed', () => { + this._updateColors(); + this._onActiveChanged(); + }, this); + + this.settings.connectObject( + 'changed::modify-quick-settings', () => { + this.modifyQuickSettings = + this.settings.get_boolean('modify-quick-settings'); + this._onActiveChanged(); + }, + 'changed::popup-in-quick-settings', () => { + this.usePopupInQuickSettings = + this.settings.get_boolean('popup-in-quick-settings'); + this._onActiveChanged(); + }, + 'changed::enable-battery-level-text', () => { + this.showBatteryPercentage = + this.settings.get_boolean('enable-battery-level-text'); + this._onActiveChanged(); + }, + 'changed::enable-battery-level-icon', () => { + this.showBatteryIcon = this.settings.get_boolean('enable-battery-level-icon'); + this._onActiveChanged(); + }, + 'changed::swap-icon-text', () => { + this.swapIconText = this.settings.get_boolean('swap-icon-text'); + this._onActiveChanged(); + }, + 'changed::sort-devices-by-history', () => { + this._sortDevicesByHistory = this.settings.get_boolean('sort-devices-by-history'); + this._onActiveChanged(); + }, + 'changed::hide-bluetooth-indicator', () => { + this._hideBluetoothIndicator = this.settings.get_int('hide-bluetooth-indicator'); + this._onActiveChanged(); + }, + 'changed::indicator-type', () => { + this._indicatorType = this.settings.get_int('indicator-type'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::panel-button-single-indicator', () => { + this._panelSingleIndicator = + this.settings.get_boolean('panel-button-single-indicator'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::enable-multi-indicator-mode', () => { + this._enableMultimodeIndicator = + this.settings.get_boolean('enable-multi-indicator-mode'); + this._onActiveChanged(); + }, + 'changed::enable-on-hover-mode', () => { + this._enableHoverMode = this.settings.get_boolean('enable-on-hover-mode'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::enable-tooltip', () => { + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::disable-level-in-icon', () => { + this.widgetInfo.disableLevelInIcon = + this.settings.get_boolean('disable-level-in-icon'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::enable-battery-indicator-text', () => { + this.widgetInfo.indicatorWithText = + this.settings.get_boolean('enable-battery-indicator-text'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::level-indicator-type', () => { + this.widgetInfo.levelIndicatorType = + this.settings.get_int('level-indicator-type'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::level-bar-position', () => { + this.widgetInfo.levelBarPosition = + this.settings.get_int('level-bar-position'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::level-indicator-color', () => { + this.widgetInfo.levelIndicatorColor = + this.settings.get_int('level-indicator-color'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::level-indicator-custom-colors', () => { + this.widgetInfo.levelIndicatorCustomColors = + this.settings.get_strv('level-indicator-custom-colors'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::circle-widget-color', () => { + this.widgetInfo.circleWidgetColor = this.settings.get_int('circle-widget-color'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::circle-widget-custom-colors', () => { + this.widgetInfo.circleWidgetCustomColors = + this.settings.get_strv('circle-widget-custom-colors'); + this._onActiveChanged(); + this._reloadUpowerIndicator(); + }, + 'changed::enable-upower-level-icon', () => { + this._upowerEnabled = this.settings.get_boolean('enable-upower-level-icon'); + this._reloadUpowerIndicator(); + }, + 'changed::enable-airpods-device', () => { + this.airpodsEnabled = this.settings.get_boolean('enable-airpods-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-sony-device', () => { + this.sonyEnabled = this.settings.get_boolean('enable-sony-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-galaxy-buds-device', () => { + this.galaxyBudsEnabled = this.settings.get_boolean('enable-galaxy-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-nothing-buds-device', () => { + this.nothingBudsEnabled = this.settings.get_boolean('enable-nothing-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-google-buds-device', () => { + this.googleBudsEnabled = this.settings.get_boolean('enable-google-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-bose-buds-device', () => { + this.boseBudsEnabled = this.settings.get_boolean('enable-bose-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-redmi-buds-device', () => { + this.redmiBudsEnabled = this.settings.get_boolean('enable-redmi-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-senh-buds-device', () => { + this.senhBudsEnabled = this.settings.get_boolean('enable-senh-buds-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + + 'changed::enable-gfps-device', () => { + this.gfpsEnabled = this.settings.get_boolean('enable-gfps-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + 'changed::enable-gattbas-device', () => { + this.gattBasEnabled = this.settings.get_boolean('enable-gattbas-device'); + this._setEnhancedDeviceMode(); + this._onActiveChanged(); + }, + this + ); + this._setEnhancedDeviceMode(); + this._connectSettingsSignal(true); + this._onActiveChanged(); + + Main.sessionMode.connectObject( + 'updated', session => { + this.widgetInfo.isUnlockSession = session.currentMode === 'unlock-dialog'; + this._onActiveChanged(); + }, + this + ); + + this._upowerEnabled = this.settings.get_boolean('enable-upower-level-icon'); + this._reloadUpowerIndicator(); + + this.sync(); + } + + _reloadUpowerIndicator() { + if (this._upowerEnabled) { + this._upowerClient?.destroy(); + this._upowerClient = new UpowerClient(this); + } else { + this._upowerClient?.destroy(); + this._upowerClient = null; + } + } + + _connectSettingsSignal(connect) { + if (connect) { + this._settingSignalId = this.settings.connect('changed::device-list', () => { + this._pullDevicesFromGsetting(); + this.sync(); + }); + } else if (this._settingSignalId) { + this.settings.disconnect(this._settingSignalId); + this._settingSignalId = null; + } + } + + _reorderDeviceItems() { + const devices = this._sortDevicesByHistory ? this._getRecencySortedDevices() + : this._bluetoothToggle._getSortedDevices(); + for (const [i, dev] of devices.entries()) { + const item = this._deviceItems.get(dev.get_object_path()); + if (!item) + continue; + + this._bluetoothToggle._deviceSection.moveMenuItem(item.popupMenuItem, i); + } + } + + _removeDevice(path) { + if (this.deviceList.has(path)) { + const props = this.deviceList.get(path); + props.paired = false; + this.deviceList.set(path, props); + this.pushDevicesToGsetting(); + } + this._deviceItems.get(path)?.destroy(); + this._deviceItems.delete(path); + this._updateDeviceVisibility(); + } + + _updateDeviceVisibility() { + this._bluetoothToggle._deviceSection.actor.visible = + [...this._deviceItems.values()].some(item => item.popupMenuItem.visible); + } + + _onActiveChanged() { + this._bluetoothToggle._updatePlaceholder(); + this._deviceItems.forEach(item => item.destroy()); + this._deviceItems.clear(); + this._updateIndicatorSettings(); + this.sync(); + // Rebuild UPower indicators in case the indicator box was just torn down. + this._reloadUpowerIndicator(); + } + + _getRecencySortedDevices() { + const devices = this._bluetoothToggle._getSortedDevices(); + const connectedDevices = []; + const disconnectedDevices = []; + + devices.forEach(device => { + const path = device.get_object_path(); + const props = this.deviceList.get(path); + if (device.connected) { + connectedDevices.push({ + device, + time: props?.connectedTime || 0, + }); + } else { + disconnectedDevices.push({ + device, + time: props?.disconnectedTime || 0, + }); + } + }); + connectedDevices.sort((a, b) => b.time - a.time); + disconnectedDevices.sort((a, b) => b.time - a.time); + return [...connectedDevices.map(item => item.device), + ...disconnectedDevices.map(item => item.device)]; + } + + _pullDevicesFromGsetting() { + this.deviceList.clear(); + const deviceList = this.settings.get_strv('device-list'); + if (deviceList.length !== 0) { + for (const jsonString of deviceList) { + const item = JSON.parse(jsonString); + const path = item.path; + const props = { + 'icon': item['icon'], + 'alias': item['alias'], + 'paired': item['paired'], + 'batteryReported': item['battery-reported'], + 'qsLevelEnabled': item['qs-level'], + 'indicatorMode': item['indicator-mode'], + 'isEnhancedDevice': item['enhanced-device'], + 'connectedTime': item['connected-time'] || 0, + 'disconnectedTime': item['disconnected-time'] || 0, + }; + this.deviceList.set(path, props); + } + } + } + + pushDevicesToGsetting() { + const deviceList = []; + for (const [path, props] of this.deviceList) { + const item = { + path, + 'icon': props.icon, + 'alias': props.alias, + 'paired': props.paired, + 'battery-reported': props.batteryReported, + 'qs-level': props.qsLevelEnabled, + 'indicator-mode': props.indicatorMode, + 'enhanced-device': props.isEnhancedDevice, + 'connected-time': props.connectedTime, + 'disconnected-time': props.disconnectedTime, + }; + deviceList.push(JSON.stringify(item)); + } + this._connectSettingsSignal(false); + this.settings.set_strv('device-list', deviceList); + this._connectSettingsSignal(true); + this.sync(); + } + + _addNewDeviceToList(device, reported, type) { + const battReported = reported || type; + const currentTime = GLib.DateTime.new_now_utc().to_unix(); + const path = device.get_object_path(); + const props = { + icon: device.icon, + alias: device.alias, + paired: device.paired, + batteryReported: battReported, + qsLevelEnabled: battReported, + indicatorMode: battReported ? 2 : 0, + isEnhancedDevice: type, + connectedTime: currentTime, + disconnectedTime: currentTime, + }; + this.deviceList.set(path, props); + this.delayedUpdateDeviceGsettings(); + } + + delayedUpdateDeviceGsettings() { + if (this._delayedTimerId) + GLib.source_remove(this._delayedTimerId); + this._delayedTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this.pushDevicesToGsetting(); + this._delayedTimerId = null; + return GLib.SOURCE_REMOVE; + }); + } + + sync() { + if (this._syncRunning) { + this._syncPending = true; + return; + } + + this._syncRunning = true; + + do { + this._syncPending = false; + this._sync(); + } while (this._syncPending); + + this._syncRunning = false; + } + + _sync() { + const devices = this._sortDevicesByHistory ? this._getRecencySortedDevices() + : this._bluetoothToggle._getSortedDevices(); + + for (const dev of devices) { + const path = dev.get_object_path(); + if (this._deviceItems.has(path)) { + const deviceWidgetManager = this._deviceItems.get(path); + if (this.enhancedDeviceManager) { + const enhancedProps = this.enhancedDeviceManager?.onDeviceSync(path, + dev.connected, dev.icon, dev.alias); + + if (deviceWidgetManager.type && enhancedProps?.dataHandler) + deviceWidgetManager.setDataHandler(enhancedProps.dataHandler); + } + deviceWidgetManager.update(); + continue; + } + + const enhancedDeviceProps = this.enhancedDeviceManager?.onDeviceSync( + path, dev.connected, dev.icon, dev.alias); + + if (this.enhancedDeviceManager && enhancedDeviceProps && + enhancedDeviceProps.pendingDetection) + continue; + + const enhanceDeviceType = enhancedDeviceProps ? enhancedDeviceProps.type : null; + + let props = {}; + let deviceIcon; + if (this.deviceList.has(path)) { + let updateGsettingPairedList = false; + props = this.deviceList.get(path); + if (props.alias !== dev.alias) { + props.alias = dev.alias; + updateGsettingPairedList = true; + } + if (props.paired !== dev.paired) { + props.paired = dev.paired; + updateGsettingPairedList = true; + } + if (!props.batteryReported && dev.battery_percentage > 0) { + props.batteryReported = true; + props.qsLevelEnabled = true; + props.indicatorMode = 2; + updateGsettingPairedList = true; + } + if (!props.isEnhancedDevice && enhanceDeviceType) { + props.isEnhancedDevice = enhanceDeviceType; + props.qsLevelEnabled = true; + props.indicatorMode = 2; + updateGsettingPairedList = true; + } + if (updateGsettingPairedList) { + this.deviceList.set(path, props); + this.delayedUpdateDeviceGsettings(); + } + deviceIcon = props.icon; + } else { + deviceIcon = supportedIcons.includes(dev.icon) ? dev.icon : 'bluetooth'; + this._addNewDeviceToList(dev, dev.battery_percentage > 0, enhanceDeviceType); + } + + const qsLevelEnabled = props.batteryReported || props.isEnhancedDevice + ? this.deviceList.get(path).qsLevelEnabled : false; + + let item; + if (this.enhancedDeviceManager && enhanceDeviceType) { + item = new WidgetManagerEnhanced(this, dev, qsLevelEnabled, props.indicatorMode, + deviceIcon, enhancedDeviceProps); + } else { + item = new WidgetManagerBluez(this, dev, props.batteryReported, qsLevelEnabled, + props.indicatorMode, deviceIcon); + } + + item.popupMenuItem.connect('notify::visible', () => this._updateDeviceVisibility()); + this._bluetoothToggle._deviceSection.addMenuItem(item.popupMenuItem); + this._deviceItems.set(path, item); + } + + const connectedDevices = devices.filter(dev => dev.connected); + const nConnected = connectedDevices.length; + + if (nConnected > 1) { + this._bluetoothToggle.subtitle = + ngettext('%d Connected', '%d Connected', nConnected).format(nConnected); + } else if (nConnected === 1) { + this._bluetoothToggle.subtitle = connectedDevices[0].alias; + } else { + this._bluetoothToggle.subtitle = null; + } + + this._updateDeviceVisibility(); + + if (this._hideBluetoothIndicator === 2) { + let indicatorCount = 0; + + this._deviceItems.forEach(item => { + if (item.indicator !== null && item.indicator !== undefined) + indicatorCount++; + }); + + QuickSettingsMenu._bluetooth._indicator.visible = indicatorCount < 1 && nConnected > 0; + } else if (this._hideBluetoothIndicator === 1) { + QuickSettingsMenu._bluetooth._indicator.visible = false; + } else { + QuickSettingsMenu._bluetooth._indicator.visible = nConnected > 0; + } + + this.enhancedDeviceManager?.updateEnhancedDevicesInstance(); + } + + _addIndicatorBoxLayout() { + this._indicatorBox = new St.BoxLayout({ + x_align: Clutter.ActorAlign.CENTER, + style_class: 'panel-status-indicators-box', + }); + if (this._enablePanelButton && !this._panelSingleIndicator && this.panelButton) { + this.panelButton.add_child(this._indicatorBox); + } else if (this.enableIndicator) { + this._indicatorBox.quickSettingsItems = []; + Main.panel.statusArea.quickSettings.addExternalIndicator(this._indicatorBox); + } + + this._indicatorBox.connectObject('child-removed', () => { + if (this._indicatorBox.get_n_children() === 0) + this._removeIndicatorBoxLayout(); + }, + this + ); + } + + _removeIndicatorBoxLayout() { + this._indicatorBox?.disconnectObject(this); + this._indicatorBox?.destroy(); + this._indicatorBox = null; + } + + addIndicatorWidget(widget) { + if (!this._indicatorBox) + this._addIndicatorBoxLayout(); + this._indicatorBox.add_child(widget); + } + + _setEnhancedDeviceMode() { + this.enhancedDeviceManager?.destroy(); + this.enhancedDeviceManager = null; + const enableEnhancedDeviceMode = [ + this.airpodsEnabled, + this.sonyEnabled, + this.galaxyBudsEnabled, + this.nothingBudsEnabled, + this.googleBudsEnabled, + this.boseBudsEnabled, + this.redmiBudsEnabled, + this.senhBudsEnabled, + this.gfpsEnabled, + this.gattBasEnabled, + ].some(Boolean); + if (enableEnhancedDeviceMode && !this.enhancedDeviceManager) + this.enhancedDeviceManager = new EnhancedDeviceSupportManager(this); + } + + _updateIndicatorSettings() { + this.enableIndicator = this._indicatorType === 1; + this._enablePanelButton = this._indicatorType === 2; + this.indicatorEnabled = + this.enableIndicator || this._enablePanelButton && !this._panelSingleIndicator; + this.multimodeIndicatorEnabled = + this.indicatorEnabled && this._enableMultimodeIndicator; + this.hoverModeEnabled = this.enableIndicator && this._enableHoverMode; + + // Tear down the shared indicator box before it gets destroyed as a side + // effect of the panel button being destroyed below. + this._removeIndicatorBoxLayout(); + + this.panelButton?.destroy(); + this.panelButton = null; + + if (!this.widgetInfo.isUnlockSession && this._enablePanelButton && !this.panelButton) { + if (this._panelSingleIndicator) { + this.panelButton = new PanelButtonSingleDevice( + this.settings, this.gIcon, this.widgetInfo); + } else { + this.panelButton = new PanelButtonMultiDevice( + this.settings, this.gIcon, this.widgetInfo); + } + Main.panel.addToStatusArea(this._extuuid, this.panelButton); + } + } + + _updateColors() { + const accentColor = getAccentColor(); + const qsBox = Main.panel.statusArea.quickSettings.menu.box; + if (!qsBox) + return; + const panelBackgroundRGB = qsBox.get_theme_node().get_background_color(); + this.widgetInfo.accentColor = accentColor; + const luminanceFactor = isDarkMode(panelBackgroundRGB) ? 15 : -5; + this.connectedColor = adjustColorLuminanceToRgba(accentColor, luminanceFactor); + } + + destroy() { + this._syncRunning = false; + this._syncPending = false; + if (this._idleTimerId) + GLib.source_remove(this._idleTimerId); + this._idleTimerId = null; + if (this._originalSync) + this._bluetoothToggle._sync = () => {}; + if (this._originalReorderDeviceItems) + this._bluetoothToggle._reorderDeviceItems = () => {}; + if (this._originalRemoveDevice) + this._bluetoothToggle._removeDevice = () => {}; + if (this._delayedTimerId) + GLib.source_remove(this._delayedTimerId); + this._delayedTimerId = null; + if (this._themeContext) + this._themeContext.disconnectObject(this); + if (this.settings) + this.settings.disconnectObject(this); + Main.sessionMode.disconnectObject(this); + this._connectSettingsSignal(false); + this._upowerClient?.destroy(); + this._upowerClient = null; + if (this._deviceItems) { + this._deviceItems.forEach(item => item.destroy()); + this._deviceItems.clear(); + } + this.enhancedDeviceManager?.destroy(); + this.enhancedDeviceManager = null; + this.panelButton?.destroy(); + this.panelButton = null; + this._deviceItems = null; + this.deviceList = null; + this._removeIndicatorBoxLayout(); + this._themeContext = null; + this.settings = null; + if (this._bluetoothToggle && this._originalRemoveDevice) + this._bluetoothToggle._removeDevice = this._originalRemoveDevice; + this._originalRemoveDevice = null; + if (this._bluetoothToggle && this._originalReorderDeviceItems) + this._bluetoothToggle._reorderDeviceItems = this._originalReorderDeviceItems; + this._originalReorderDeviceItems = null; + if (this._bluetoothToggle && this._originalSync) + this._bluetoothToggle._sync = this._originalSync; + this._originalSync = null; + if (this._bluetoothToggle && this._originalOnActiveChanged) + this._bluetoothToggle._onActiveChanged = this._originalOnActiveChanged; + this._originalOnActiveChanged = null; + if (QuickSettingsMenu._bluetooth && this._originalBluetoothIndicatorSync) + QuickSettingsMenu._bluetooth._sync = this._originalBluetoothIndicatorSync; + this._originalBluetoothIndicatorSync = null; + this._bluetoothToggle?._onActiveChanged(); + } +}); + diff --git a/bbm/lib/bluezDeviceProxy.js b/bbm/lib/bluezDeviceProxy.js new file mode 100644 index 0000000000..28c7932e0a --- /dev/null +++ b/bbm/lib/bluezDeviceProxy.js @@ -0,0 +1,22 @@ +'use strict'; +import Gio from 'gi://Gio'; + +const BluezDeviceInterface = ` + + + + + + + + + + + +`; + +const BluezDeviceProxy = Gio.DBusProxy.makeProxyWrapper(BluezDeviceInterface); + +export function getBluezDeviceProxy(path) { + return new BluezDeviceProxy(Gio.DBus.system, 'org.bluez', path); +} diff --git a/bbm/lib/compat.js b/bbm/lib/compat.js new file mode 100644 index 0000000000..071260f0f7 --- /dev/null +++ b/bbm/lib/compat.js @@ -0,0 +1,46 @@ +'use strict'; +// Context-agnostic shims for GNOME Shell-only APIs. +// When imported inside a running GNOME Shell extension the real implementations +// are loaded; when imported by the standalone GJS daemon the try/catch blocks +// catch the missing resource:// paths and fall back to no-op stubs so all +// device logic continues to work without modification. + +// ── Gettext ────────────────────────────────────────────────────────────────── +let _gettext; +try { + _gettext = (await import('resource:///org/gnome/shell/extensions/extension.js')).gettext; +} catch { + _gettext = s => s; +} +export const gettext = _gettext; +export const _ = _gettext; + +// ── MediaController ─────────────────────────────────────────────────────────── +// Used by AirPods, Bose, Galaxy Buds, and Sennheiser devices for +// wear-detection / A2DP output routing. The stub disables those features +// silently when running as a daemon. +let _MediaController; +try { + _MediaController = (await import('./mediaController.js')).MediaController; +} catch { + _MediaController = class MediaController { + constructor() {} + get output_is_a2dp() { return false; } + connect() { return 0; } + disconnect() {} + destroy() {} + }; +} +export { _MediaController as MediaController }; + +// ── Notifier ────────────────────────────────────────────────────────────────── +// Used by EnhancedDeviceSupportManager to show a notification when BlueZ +// profile registration fails. Returns null in daemon context so the caller +// can fall back to toggle.notifyCb. +let _Notifier; +try { + _Notifier = (await import('./devices/notifier.js')).Notifier; +} catch { + _Notifier = null; +} +export { _Notifier as Notifier }; diff --git a/bbm/lib/dataHandler.js b/bbm/lib/dataHandler.js new file mode 100644 index 0000000000..04e025163a --- /dev/null +++ b/bbm/lib/dataHandler.js @@ -0,0 +1,198 @@ +'use strict'; +import GObject from 'gi://GObject'; + +export function createConfig() { + // Assigning batteryIcon/setButtons a name(string) enables it. + // commonIcon: St.Drawing vector name for indicator widget in indicatorVectorImages + // Also for svg icon for panel button. + // (`bbm-${config.commonIcon}-symbolic.svg`) in folder + // ${extensions directory}/icons/hicolor/scalable/actions + // battery*Icon: St.Drawing vector name for circular widget in circularBatteryVectorImages + // battery*ShowOnDisconnect: false: Hide the circular battery widget when disconnected + // true: Shows the circular battery widget + // when disconnected with disconnect icon + // toggle1/2Button*Icon: toggle set, accepts svg icon name located at + // ${extensions directory}/icons/hicolor/scalable/actions + // assign at least 2 icons to active toggle + // toggle1/2Title: title for toggle + // + // panelButtonLabelFixed: Position of bat1/2 label relative to the icon + // true: position bat1 : left, bat2 : right. if bat1/2 = 0 display '...' + // false; if bat2 = 0, bat1 : right. else bat1 : left, bat2: right. + // optionsBox*: accepts widget type [ 'slider' , 'check-button' , 'radio-button' ] + // accepts only one widget type. Cannot accept duplicates, example 2 sliders + // 4 optionBoxes are availabe, but only 1 will be displayed. + // box*SliderTitle: Title for slider box + // box*CheckButton: Can have either 1 or 2 check button per box. + // Elements in array will be used as titles + // example to define 2 checkbutton ['title1', 'title2'] + // box*RadioButton: Can have either 2 - 4 radio button per box. + // Elements in array will be used as button name + // example to define 2 radio button ['button name 1', 'button name2'] + // box*RadioTitle: Title for Radio Box + // labelIndicatorEnabled: No of Indicators on panel, displaying info. max 3. + // labelWidth: Adjust width depending on text (em). + + return { + commonIcon: null, + albumArtIcon: null, + + battery1Icon: null, + battery2Icon: null, + battery3Icon: null, + battery1ShowOnDisconnect: false, + battery2ShowOnDisconnect: false, + battery3ShowOnDisconnect: false, + + toggle1Title: '', + toggle1Button1Icon: null, + toggle1Button1Name: '', + toggle1Button2Icon: null, + toggle1Button2Name: '', + toggle1Button3Icon: null, + toggle1Button3Name: '', + toggle1Button4Icon: null, + toggle1Button4Name: '', + + optionsBox1: [], + optionsBox2: [], + optionsBox3: [], + optionsBox4: [], + box1SliderTitle: '', + box2SliderTitle: '', + box3SliderTitle: '', + box4SliderTitle: '', + box1CheckButton: [], + box2CheckButton: [], + box3CheckButton: [], + box4CheckButton: [], + box1RadioButton: [], + box1RadioTitle: '', + box2RadioButton: [], + box2RadioTitle: '', + box3RadioButton: [], + box3RadioTitle: '', + box4RadioButton: [], + box4RadioTitle: '', + + toggle2Title: '', + toggle2Button1Icon: null, + toggle2Button1Name: '', + toggle2Button2Icon: null, + toggle2Button2Name: '', + toggle2Button3Icon: null, + toggle2Button3Name: '', + toggle2Button4Icon: null, + toggle2Button4Name: '', + + labelWidth: 4, + labelIndicatorEnabled: 0, + + panelButtonLabelFixed: true, + showSettingsButton: false, + }; +} + +export function createProperties() { + // computedBatteryLevel: accepted value 0 - 100. Computed value for indicator and popupmenu + // battery*Level: accepted value 0 - 100. displays level on circular widget + // battery*Status: accepted value : 'charging', 'discharging', 'disconnected' + // toggle*State: accepted value: 0 - 3 + // 0: no buttons active, + // 1: button1 is active, other inactive + // 2: button2 is active, other inactive + // 3: button3 is active, other inactive + // 4: button3 is active, other inactive + // toggle1Visible: accepted value: boolean + // toggle2Visible: accepted value: boolean + // optionsBoxVisible: accepted value: 0 - 4 + // Current Box visible. 0 = all hidden , (1 - 4) displays optionBox(1 - 4) + // box*SliderValue: accepted value: 0 - 100. Write the value to change slider position. + // box*CheckButton*State: accepted value : 0 - 1. 0 - false 1 - true + // box*RadioButtonState: accepted value 0 - 3. 0 Non selected, 1 - 3 button selected + return { + computedBatteryLevel: 0, + battery1Level: 0, + battery2Level: 0, + battery3Level: 0, + battery1Status: 'not-reported', + battery2Status: 'not-reported', + battery3Status: 'not-reported', + + toggle1State: 0, + toggle1Visible: false, + + optionsBoxVisible: 0, + box1SliderValue: 0, + box2SliderValue: 0, + box3SliderValue: 0, + box4SliderValue: 0, + box1CheckButton1State: 0, + box1CheckButton2State: 0, + box2CheckButton1State: 0, + box2CheckButton2State: 0, + box3CheckButton1State: 0, + box3CheckButton2State: 0, + box4CheckButton1State: 0, + box4CheckButton2State: 0, + box1RadioButtonState: 0, + box2RadioButtonState: 0, + box3RadioButtonState: 0, + box4RadioButtonState: 0, + + labelIndicator1: '', + labelIndicator2: '', + labelIndicator3: '', + + toggle2State: 0, + toggle2Visible: false, + + }; +} + +// 'ui-action': emitted when user change state or toggle button, or move slider +// Signal: 'ui-action', widgetId(string), value(int) +// toggle*State: toggleButton activate, value = Button index +// box*SliderValue: slider position changed, value = Slider position +// box*SliderIsDragging: slider, isDragging, value = 0(fasle) 1(true) +// box*CheckButton*State: check button state changed, value = 0(fasle) 1(true) +// box*RadioButtonState: Radio button activated, value = button index +// settingsButtonClicked: Setting button clicked + +export const DataHandler = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_DataHandler', + Signals: { + 'configuration-changed': {}, + 'properties-changed': {}, + 'ui-action': {param_types: [GObject.TYPE_STRING, GObject.TYPE_INT]}, + }, +}, class DataHandler extends GObject.Object { + constructor(config, props) { + super(); + this.config = config; + this.props = props; + } + + getConfig() { + return this.config; + } + + setConfig(config) { + this.config = config; + this.emit('configuration-changed'); + } + + + setProps(prop) { + this.props = prop; + this.emit('properties-changed'); + } + + getProps() { + return this.props; + } + + emitUIAction(actionName, value) { + this.emit('ui-action', actionName, value); + } +}); diff --git a/bbm/lib/devices/airpods/airpodsConfig.js b/bbm/lib/devices/airpods/airpodsConfig.js new file mode 100644 index 0000000000..68c1a95392 --- /dev/null +++ b/bbm/lib/devices/airpods/airpodsConfig.js @@ -0,0 +1,231 @@ +'use strict'; +import AirPods1stGen from './deviceConfigs/AirPods1stGen.js'; +import AirPods2ndGen from './deviceConfigs/AirPods2ndGen.js'; +import AirPods3rdGen from './deviceConfigs/AirPods3rdGen.js'; +import AirPods4thGen from './deviceConfigs/AirPods4thGen.js'; +import AirPods4thGenANC from './deviceConfigs/AirPods4thGenANC.js'; +import AirPodsMax from './deviceConfigs/AirPodsMax.js'; +import AirPodsMax2 from './deviceConfigs/AirPodsMax2.js'; +import AirPodsMaxUsbC from './deviceConfigs/AirPodsMaxUsbC.js'; +import AirPodsPro from './deviceConfigs/AirPodsPro.js'; +import AirPodsPro2ndGen from './deviceConfigs/AirPodsPro2ndGen.js'; +import AirPodsPro2ndGenUsbC from './deviceConfigs/AirPodsPro2ndGenUsbC.js'; +import AirPodsPro3rdGen from './deviceConfigs/AirPodsPro3rdGen.js'; +import BeatsFitPro from './deviceConfigs/BeatsFitPro.js'; +import BeatsFlex from './deviceConfigs/BeatsFlex.js'; +import BeatsSolo3 from './deviceConfigs/BeatsSolo3.js'; +import BeatsSolo4 from './deviceConfigs/BeatsSolo4.js'; +import BeatsSoloBuds from './deviceConfigs/BeatsSoloBuds.js'; +import BeatsSoloPro from './deviceConfigs/BeatsSoloPro.js'; +import BeatsStudio3 from './deviceConfigs/BeatsStudio3.js'; +import BeatsStudioBuds from './deviceConfigs/BeatsStudioBuds.js'; +import BeatsStudioBudsPlus from './deviceConfigs/BeatsStudioBudsPlus.js'; +import BeatsStudioPro from './deviceConfigs/BeatsStudioPro.js'; +import BeatsX from './deviceConfigs/BeatsX.js'; +import Powerbeats3 from './deviceConfigs/Powerbeats3.js'; +import Powerbeats4 from './deviceConfigs/Powerbeats4.js'; +import PowerbeatsPro from './deviceConfigs/PowerbeatsPro.js'; +import PowerbeatsPro2 from './deviceConfigs/PowerbeatsPro2.js'; +import PowerbeatsFit from './deviceConfigs/PowerbeatsFit.js'; + +export const AirpodsModelList = [ + AirPods1stGen, + AirPods2ndGen, + AirPods3rdGen, + AirPods4thGen, + AirPods4thGenANC, + AirPodsMax, + AirPodsMax2, + AirPodsMaxUsbC, + AirPodsPro, + AirPodsPro2ndGen, + AirPodsPro2ndGenUsbC, + AirPodsPro3rdGen, + BeatsFitPro, + BeatsFlex, + BeatsSolo3, + BeatsSolo4, + BeatsSoloBuds, + BeatsSoloPro, + BeatsStudio3, + BeatsStudioBuds, + BeatsStudioBudsPlus, + BeatsStudioPro, + BeatsX, + Powerbeats3, + Powerbeats4, + PowerbeatsPro, + PowerbeatsPro2, + PowerbeatsFit, +]; + + +export const BatteryType = { + SINGLE: 0x01, + RIGHT: 0x02, + LEFT: 0x04, + CASE: 0x08, +}; + +export const BatteryChargingStatus = { + CHARGING: 0x01, + DISCHARGING: 0x02, + DISCONNECTED: 0x04, +}; + +export const EarDetection = { + IN_EAR: 0x00, + OUT_EAR: 0x01, + IN_CASE: 0x02, +}; + +export const ANCMode = { + ANC_OFF: 0x01, + ANC_ON: 0x02, + TRANSPARENCY: 0x03, + ADAPTIVE: 0x04, +}; + +export const LongPressBits = { + off: 0x01, + anc: 0x02, + transparency: 0x04, + adaptive: 0x08, +}; + +export const AwarenessMode = { + ON: 0x01, + OFF: 0x02, +}; + +export const PressSpeedMode = { + DEFAULT: 0x00, + SLOWER: 0x01, + SLOWEST: 0x02, +}; + +export const PressDurationMode = { + DEFAULT: 0x00, + SHORTER: 0x01, + SHORTEST: 0x02, +}; + +export const VolSwipeLength = { + DEFAULT: 0x00, + LONGER: 0x01, + LONGEST: 0x02, +}; + +export const VolSwipeMode = { + ON: 0x01, + OFF: 0x02, +}; + +export const PacketConstants = { + SETTINGS: [0x09, 0x00], + PREFIX: [0x04, 0x00, 0x04, 0x00], + SUFFIX: [0x00, 0x00, 0x00], + + HANDSHAKE: Uint8Array.from([ + 0x00, 0x00, 0x04, 0x00, 0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]), + + SET_SPECIFIC_FEATURES: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x4d, 0x00, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + + REQUEST_NOTIFICATIONS: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x0f, 0x00, 0xff, 0xff, 0xff, 0xff, + ]), + + HANDSHAKE_ACK: Uint8Array.from([ + 0x01, 0x00, 0x04, 0x00, + ]), + + FEATURES_ACK: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x2b, 0x00, + ]), + + BATTERY_STATUS_NOTIFICATION: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x04, 0x00, + ]), + + CONVERSATION_AWARENESS_DATA: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x4b, 0x00, 0x02, 0x00, 0x01, + ]), + + EAR_DETECTION_PREFIX: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x06, 0x00, + ]), + + ADAPTIVE_CONTROL_HEADER: Uint8Array.from([ + 0x04, 0x00, 0x04, 0x00, 0x09, 0x00, 0x2e, + ]), + + CASE_SOUND_HEADER: Uint8Array.from([ + 0x12, 0x3a, 0x00, 0x01, 0x01, 0x08, + ]), + +}; + +PacketConstants.NOISE_CANCELLATION_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x0d, +]); + +PacketConstants.NOISE_CANCELLATION_OFF = Uint8Array.from([ + ...PacketConstants.NOISE_CANCELLATION_HEADER, 0x01, ...PacketConstants.SUFFIX, +]); + +PacketConstants.NOISE_CANCELLATION_ON = Uint8Array.from([ + ...PacketConstants.NOISE_CANCELLATION_HEADER, 0x02, ...PacketConstants.SUFFIX, +]); + +PacketConstants.NOISE_CANCELLATION_TRANSPARENCY = Uint8Array.from([ + ...PacketConstants.NOISE_CANCELLATION_HEADER, 0x03, ...PacketConstants.SUFFIX, +]); + +PacketConstants.NOISE_CANCELLATION_ADAPTIVE = Uint8Array.from([ + ...PacketConstants.NOISE_CANCELLATION_HEADER, 0x04, ...PacketConstants.SUFFIX, +]); + +PacketConstants.CONVERSATION_AWARENESS_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x28, +]); + +PacketConstants.CONVERSATION_AWARENESS_OFF = Uint8Array.from([ + ...PacketConstants.CONVERSATION_AWARENESS_HEADER, 0x02, ...PacketConstants.SUFFIX, +]); + +PacketConstants.CONVERSATION_AWARENESS_ON = Uint8Array.from([ + ...PacketConstants.CONVERSATION_AWARENESS_HEADER, 0x01, ...PacketConstants.SUFFIX, +]); + +PacketConstants.PRESS_SPEED_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x17, +]); + +PacketConstants.PRESS_AND_HOLD_DURATION_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x18, +]); + +PacketConstants.VOL_SWIPE_MODE_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x25, +]); + +PacketConstants.VOL_SWIPE_LENGHT_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x23, +]); + +PacketConstants.NOTI_VOLUME_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x1f, +]); + +PacketConstants.LONGPRESS_CYCLE_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x1a, +]); + +PacketConstants.LISTENING_MODE_HEADER = Uint8Array.from([ + ...PacketConstants.PREFIX, ...PacketConstants.SETTINGS, 0x34, +]); + diff --git a/bbm/lib/devices/airpods/airpodsDevice.js b/bbm/lib/devices/airpods/airpodsDevice.js new file mode 100644 index 0000000000..97f6c57232 --- /dev/null +++ b/bbm/lib/devices/airpods/airpodsDevice.js @@ -0,0 +1,784 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import {buds2to1BatteryLevel, validateProperties, launchConfigureWindow} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {AirpodsSocket} from './airpodsSocket.js'; +import { + AirpodsModelList, ANCMode, AwarenessMode, EarDetection, + PressSpeedMode, PressDurationMode, VolSwipeLength +} from './airpodsConfig.js'; + +export const DeviceTypeAirpods = 'airpods'; + +const AirpodsUUID = '74ec2172-0bad-4d01-8f77-997b2be0722a'; +export function isAirpods(bluezDeviceProxy, uuids) { + const bluezProps = ['Modalias']; + let supported = 'no'; + + if (!uuids.includes(AirpodsUUID)) + return {supported, bluezProps}; + + const modalias = bluezDeviceProxy.Modalias; + if (!modalias) { + supported = 'pending'; + return {supported, bluezProps}; + } + + const regex = /v004Cp([0-9A-Fa-f]{4})d/; + const match = modalias.match(regex); + if (!match) + return {supported, bluezProps}; + + const model = match[1].toUpperCase(); + if (AirpodsModelList.some(m => m.key === model)) + supported = 'yes'; + + return {supported, bluezProps}; +} + +export const AirpodsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_AirpodsDevice', +}, class AirpodsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `AirpodsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- AirpodsDevice init -------------------'); + const now = Date.now(); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + + this._config = createConfig(); + this._props = createProperties(); + this._model = null; + this._budInEar = false; + this._bothBudsInEar = false; + this._battInfoRecieved = false; + this._previousOnDestroyVolume = -1; + this._ignoreGsettingsChange = false; + + const attStrings = this._settings.get_strv('attenuated-on-destroy-info'); + if (attStrings.length > 0) { + const {path, timestamp, volume} = JSON.parse(attStrings[0]); + if (path === this._devicePath && now - timestamp <= 500) + this._previousOnDestroyVolume = volume; + } + + this._callbacks = { + updateBatteryProps: this.updateBatteryProps.bind(this), + updateAncMode: this.updateAncMode.bind(this), + updateAdaptiveLevel: this.updateAdaptiveLevel.bind(this), + updateAwarenessMode: this.updateAwarenessMode.bind(this), + updateAwarenessData: this.updateAwarenessData.bind(this), + updateInEarStatus: this.updateInEarStatus.bind(this), + + updatePressSpeed: this.updatePressSpeed.bind(this), + updatePressDuration: this.updatePressDuration.bind(this), + updateVolSwipeLength: this.updateVolSwipeLength.bind(this), + updateVolSwipeMode: this.updateVolSwipeMode.bind(this), + updateNotificationToneMode: this.updateNotificationToneMode.bind(this), + }; + this._initialize(profileManager); + } + + _initialize(profileManager) { + this._bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const modalias = this._bluezDeviceProxy.Modalias; + const regex = /v004Cp([0-9A-Fa-f]{4})d/; + const match = modalias.match(regex); + if (!match) + return; + + this._model = match[1].toUpperCase(); + this._modelData = AirpodsModelList.find(m => m.key === this._model); + this._log.info(`Configuration: ${JSON.stringify(this._modelData, null, 2)}`); + + this._batteryType = this._modelData.batteryType; + this._ancSupported = this._modelData.ancSupported ?? false; + this._adaptiveSupported = this._modelData.adaptiveSupported ?? false; + this._awarenessSupported = this._modelData.awarenessSupported ?? false; + this._pressSpeedDurationSupported = + this._modelData.pressSpeedDurationSupported ?? false; + this._volumeSwipeSupported = this._modelData.volumeSwipeSupported ?? false; + this._longPressCycleSupported = this._modelData.longPressCycleSupported ?? false; + this._toneVolumeSupported = this._modelData.toneVolumeSupported ?? false; + this._enableTurnOffListeningMode = this._modelData.enableTurnOffListeningMode ?? false; + + this._commonIcon = this._modelData.budsIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.showSettingsButton = true; + + if (this._batteryType !== 1) + this._caseIcon = `${this._modelData.case}`; + + this._createDefaultSettings(); + + const devicesList = this._settings.get_strv('airpods-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'airpods-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorAirpodsListGsettings(); + this._updateIcons(); + this._updateAncConfig(); + this._updateAwarnessConfig(); + + const profile = {type: DeviceTypeAirpods, uuid: AirpodsUUID}; + + this._airpodsSocket = new AirpodsSocket( + this._devicePath, + profileManager, + profile, + this._modelData, + this._callbacks + ); + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1ShowOnDisconnect = true; + if (this._batteryType !== 1) { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery2ShowOnDisconnect = true; + this._config.battery3Icon = this._caseIcon; + } else { + this._config.battery1Icon = this._commonIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + _updateAncConfig() { + if (!this._ancSupported) + return; + + this._config.toggle1Title = _('Noise Control'); + + const allowOff = !this._enableTurnOffListeningMode || + this._enableTurnOffListeningMode && this._lisMode; + + const modes = []; + + if (allowOff) + modes.push('off'); + + modes.push('transparency'); + + if (this._adaptiveSupported) + modes.push('adaptive'); + + modes.push('anc'); + + this._toggle1Modes = modes; + + const icons = { + off: 'bbm-anc-off-symbolic', + transparency: 'bbm-transperancy-symbolic', + adaptive: 'bbm-adaptive-symbolic', + anc: 'bbm-anc-on-symbolic', + }; + + const labels = { + off: _('Off'), + transparency: _('Transparency'), + adaptive: _('Adaptive'), + anc: _('Noise Cancellation'), + }; + + const ncModes = { + off: ANCMode.ANC_OFF, + transparency: ANCMode.TRANSPARENCY, + adaptive: ANCMode.ADAPTIVE, + anc: ANCMode.ANC_ON, + }; + + this._toggle1ButtonToAncMode = []; + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Icon`] = ''; + this._config[`toggle1Button${i}Name`] = ''; + this._toggle1ButtonToAncMode[i] = null; + } + + modes.forEach((mode, index) => { + const button = index + 1; + + this._config[`toggle1Button${button}Icon`] = icons[mode]; + this._config[`toggle1Button${button}Name`] = labels[mode]; + + this._toggle1ButtonToAncMode[button] = ncModes[mode]; + }); + + if (this._adaptiveSupported) { + this._config.optionsBox1 = ['slider']; + this._config.box1SliderTitle = _('Ambient Level'); + } + } + + _updateAwarnessConfig() { + if (this._awarenessSupported) { + this._config.toggle2Title = _('Conversation Awareness'); + this._config.toggle2Button1Icon = 'bbm-ca-on-symbolic'; + this._config.toggle2Button1Name = _('On'); + this._config.toggle2Button2Icon = 'bbm-ca-off-symbolic'; + this._config.toggle2Button2Name = _('Off'); + } + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + model: this._model, + alias: this._alias, + icon: this._commonIcon, + + ...this._batteryType !== 1 && { + 'case': this._caseIcon, + }, + + 'wear-detection-mode': 1, + + ...this._enableTurnOffListeningMode && { + 'listening-mode': true, + }, + + ...this._awarenessSupported && { + 'ca-volume-enabled': true, + 'ca-volume': 20, + }, + + ...this._longPressCycleSupported && { + 'lp-value': 6, + }, + + ...this._toneVolumeSupported && { + 'noti-vol': 77, + }, + + ...this._volumeSwipeSupported && { + 'swipe-mode': true, + 'swipe-len': 0, + }, + + ...this._pressSpeedDurationSupported && { + 'press-speed': 0, + 'press-dur': 0, + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('airpods-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('airpods-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._batteryType !== 1) + this._caseIcon = this._settingsItems['case']; + + if (this._awarenessSupported) { + this._caVolEnabled = this._settingsItems['ca-volume-enabled']; + this._caVolume = this._settingsItems['ca-volume']; + } + + if (this._enableTurnOffListeningMode) + this._lisMode = this._settingsItems['listening-mode']; + + if (this._longPressCycleSupported) + this._lpValue = this._settingsItems['lp-value']; + + if (this._toneVolumeSupported) + this._notiVolume = this._settingsItems['noti-vol']; + + if (this._volumeSwipeSupported) { + this._swipeMode = this._settingsItems['swipe-mode']; + this._swipeLength = this._settingsItems['swipe-len']; + } + + if (this._pressSpeedDurationSupported) { + this._pressSpeed = this._settingsItems['press-speed']; + this._pressDur = this._settingsItems['press-dur']; + } + + this._wearDetectionMode = this._settingsItems['wear-detection-mode']; + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('airpods-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._batteryType !== 1) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + const wearDetectionMode = this._settingsItems['wear-detection-mode']; + if (this._wearDetectionMode !== wearDetectionMode) { + this._wearDetectionMode = wearDetectionMode; + this._configureMediaController(); + } + + if (this._awarenessSupported) { + this._caVolume = this._settingsItems['ca-volume']; + if (this._caVolEnabled && !this._settingsItems['ca-volume-enabled']) + this._mediaController?.setConversationAwarenessVolume(false, this._caVolume); + + this._caVolEnabled = this._settingsItems['ca-volume-enabled']; + } + + if (this._enableTurnOffListeningMode) { + const lisMode = this._settingsItems['listening-mode']; + if (this._lisMode !== lisMode) { + this._lisMode = lisMode; + this._setListMode(lisMode); + } + } + + if (this._longPressCycleSupported) { + const lpValue = this._settingsItems['lp-value']; + if (this._lpValue !== lpValue) { + this._lpValue = lpValue; + this._setLongpressCycle(lpValue); + } + } + + if (this._toneVolumeSupported) { + const notiVol = this._settingsItems['noti-vol']; + if (this._notiVolume !== notiVol) { + this._notiVolume = notiVol; + this._setNotiVolume(notiVol); + } + } + + if (this._volumeSwipeSupported) { + const swipeMode = this._settingsItems['swipe-mode']; + if (this._swipeMode !== swipeMode) { + this._swipeMode = swipeMode; + this._setSwipeMode(swipeMode); + } + + const swipeLen = this._settingsItems['swipe-len']; + if (this._swipeLength !== swipeLen) { + this._swipeLength = swipeLen; + this._setSwipeLength(swipeLen); + } + } + + if (this._pressSpeedDurationSupported) { + const pressSpeed = this._settingsItems['press-speed']; + if (this._pressSpeed !== pressSpeed) { + this._pressSpeed = pressSpeed; + this._setPressSpeed(pressSpeed); + } + + const pressDur = this._settingsItems['press-dur']; + if (this._pressDur !== pressDur) { + this._pressDur = pressDur; + this._setPressDur(pressDur); + } + } + } + + _monitorAirpodsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::airpods-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('airpods-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('airpods-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + this._configureMediaController(); + + if (this._enableTurnOffListeningMode) + this._setListMode(this._lisMode, true); + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'toggle2State') + this._toggle2ButtonClicked(value); + + if (command === 'box1SliderValue') + this._setSliderChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + _configureMediaController() { + // MediaController not available in daemon context + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + if (this._batteryType === 1) + this._props.computedBatteryLevel = props.battery1Level; + else + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + + if (!this._battInfoRecieved) + this._startConfiguration(props); + + this.dataHandler?.setProps(this._props); + } + + updateAncMode(ancMode) { + if (!this._ancSupported) + return; + + if (ancMode !== null) + this._ancMode = ancMode; + + let index = this._toggle1ButtonToAncMode?.findIndex(v => v === this._ancMode); + + if (index < 0) + index = 0; + + this._props.toggle1State = index; + + this._props.optionsBoxVisible = + this._adaptiveSupported && ancMode === ANCMode.ADAPTIVE ? 1 : 0; + + this.dataHandler?.setProps(this._props); + } + + updateAdaptiveLevel(level) { + const inverseLevel = 100 - level; + this._inverseLevel = inverseLevel; + this._props.box1SliderValue = inverseLevel; + this.dataHandler?.setProps(this._props); + } + + updateAwarenessMode(mode) { + if (!this._awarenessSupported) + return; + + if (mode === AwarenessMode.ON) + this._props.toggle2State = 1; + else if (mode === AwarenessMode.OFF) + this._props.toggle2State = 2; + + this.dataHandler?.setProps(this._props); + } + + _updatetoggleVisibility() { + const toggle1Visible = this._budInEar && this._ancSupported; + const toggle2Visible = + this._bothBudsInEar && this._awarenessSupported && this._outputIsA2dp; + + this._props.toggle1Visible = toggle1Visible; + this._props.toggle2Visible = toggle2Visible; + this.dataHandler?.setProps(this._props); + } + + updateInEarStatus(bud1Status, bud2Status) { + this._bothBudsInEar = + bud1Status === EarDetection.IN_EAR && bud2Status === EarDetection.IN_EAR; + + this._budInEar = + bud1Status === EarDetection.IN_EAR || bud2Status === EarDetection.IN_EAR; + + this._updatetoggleVisibility(); + + if (this._wearDetectionMode !== 0) { + let playbackMode = null; + + if (this._wearDetectionMode === 1) + playbackMode = this._bothBudsInEar ? 'play' : 'pause'; + else if (this._wearDetectionMode === 2) + playbackMode = this._budInEar ? 'play' : 'pause'; + + if (playbackMode) + this._mediaController?.changeActivePlayerState(playbackMode); + } + } + + updateAwarenessData(attenuated) { + if (this._awarenessSupported && this._caVolEnabled) + this._mediaController?.setConversationAwarenessVolume(attenuated, this._caVolume); + } + + updateNotificationToneMode(level) { + if (!this._toneVolumeSupported) + return; + + this._notiVolume = level; + + if (this._settingsItems) { + this._settingsItems['noti-vol'] = level; + this._updateGsettings(); + } + } + + updateVolSwipeMode(state) { + if (!this._volumeSwipeSupported) + return; + + this._swipeMode = state; + + if (this._settingsItems) { + this._settingsItems['swipe-mode'] = state; + this._updateGsettings(); + } + } + + updateVolSwipeLength(duration) { + if (!this._volumeSwipeSupported) + return; + + let index; + if (duration === VolSwipeLength.DEFAULT) + index = 0; + else if (duration === VolSwipeLength.LONGER) + index = 1; + else + index = 2; + + this._swipeLength = index; + + if (this._settingsItems) { + this._settingsItems['swipe-len'] = index; + this._updateGsettings(); + } + } + + updatePressSpeed(speed) { + if (!this._pressSpeedDurationSupported) + return; + + let index; + if (speed === PressSpeedMode.DEFAULT) + index = 0; + else if (speed === PressSpeedMode.SLOWER) + index = 1; + else + index = 2; + + this._pressSpeed = index; + + if (this._settingsItems) { + this._settingsItems['press-speed'] = index; + this._updateGsettings(); + } + } + + updatePressDuration(duration) { + let index; + if (duration === PressDurationMode.DEFAULT) + index = 0; + else if (duration === PressDurationMode.SLOWER) + index = 1; + else + index = 2; + + this._pressDur = index; + + if (this._settingsItems) { + this._settingsItems['press-dur'] = index; + this._updateGsettings(); + } + } + + _toggle1ButtonClicked(index) { + const ancMode = this._toggle1ButtonToAncMode?.[index]; + if (ancMode == null) + return; + + this._props.toggle1State = index; + this._props.optionsBoxVisible = + this._adaptiveSupported && ancMode === ANCMode.ADAPTIVE ? 1 : 0; + this.dataHandler?.setProps(this._props); + this._airpodsSocket?.setAncMode(ancMode); + } + + _toggle2ButtonClicked(index) { + this._props.toggle2State = index; + this.dataHandler?.setProps(this._props); + if (index === 1) + this._airpodsSocket?.setAwarenessMode(AwarenessMode.ON); + else if (index === 2) + this._airpodsSocket?.setAwarenessMode(AwarenessMode.OFF); + } + + _setSliderChanged(level) { + const inverseLevel = 100 - level; + if (this._inverseLevel !== inverseLevel) { + this._inverseLevel = inverseLevel; + this._airpodsSocket?.setAdaptiveLevel(inverseLevel); + } + } + + _setListMode(lisMode, atInit = false) { + if (!this._enableTurnOffListeningMode) + return; + + this._airpodsSocket?.setListMode(lisMode); + if (!atInit) { + this._updateAncConfig(); + this.dataHandler?.setConfig(this._config); + this.updateAncMode(this._ancMode); + } + } + + _setLongpressCycle(cyclicValue) { + if (!this._longPressCycleSupported) + return; + + this._airpodsSocket?.setLongpressCycle(cyclicValue); + } + + _setNotiVolume(volume) { + if (!this._toneVolumeSupported) + return; + + this._airpodsSocket?.setNotiVolume(volume); + } + + _setSwipeMode(state) { + if (!this._volumeSwipeSupported) + return; + + this._airpodsSocket?.setSwipeMode(state); + } + + _setSwipeLength(index) { + if (!this._volumeSwipeSupported) + return; + + if (index === 0) + this._airpodsSocket?.setSwipeLength(VolSwipeLength.DEFAULT); + else if (index === 1) + this._airpodsSocket?.setSwipeLength(VolSwipeLength.LONGER); + else if (index === 2) + this._airpodsSocket?.setSwipeLength(VolSwipeLength.LONGEST); + } + + _setPressSpeed(index) { + if (!this._pressSpeedDurationSupported) + return; + + if (index === 0) + this._airpodsSocket?.setPressSpeed(PressSpeedMode.DEFAULT); + else if (index === 1) + this._airpodsSocket?.setPressSpeed(PressSpeedMode.SLOWER); + else if (index === 2) + this._airpodsSocket?.setPressSpeed(PressSpeedMode.SLOWEST); + } + + _setPressDur(index) { + if (!this._pressSpeedDurationSupported) + return; + + if (index === 0) + this._airpodsSocket?.setPressDur(PressDurationMode.DEFAULT); + else if (index === 1) + this._airpodsSocket?.setPressDur(PressDurationMode.SHORTER); + else if (index === 2) + this._airpodsSocket?.setPressDur(PressDurationMode.SHORTEST); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'airpods', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + this._airpodsSocket?.destroy(); + this._airpodsSocket = null; + this._bluezDeviceProxy = null; + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + if (this._mediaHandlerId) + this._mediaController?.disconnect(this._mediaHandlerId); + this._mediaHandlerId = null; + this._mediaController?.destroy(); + this._mediaController = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + diff --git a/bbm/lib/devices/airpods/airpodsSocket.js b/bbm/lib/devices/airpods/airpodsSocket.js new file mode 100644 index 0000000000..f400a599ae --- /dev/null +++ b/bbm/lib/devices/airpods/airpodsSocket.js @@ -0,0 +1,434 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {isValidByte} from '../deviceUtils.js'; +import { + PacketConstants, BatteryType, BatteryChargingStatus, EarDetection, ANCMode, AwarenessMode, + PressSpeedMode, PressDurationMode, VolSwipeLength, VolSwipeMode +} from './airpodsConfig.js'; + +/** +Airpods / Beats module for Bluetooth battery meter service to provide, +battery information, ANC and Convesational awareness on device that support it. + +Credits: +https://github.com/steam3d/MagicPodsCore +steam3d for reverse battery reporting , ANC control + +https://github.com/kavishdevar/librepods +kavishdevar for Conversation awarness and AAP Definations +**/ + +export const AirpodsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_AirpodsSocket', +}, class AirpodsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, modelData, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `AirpodsSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('AirpodsSocket init'); + + this._ancSupported = modelData.ancSupported ?? false; + this._adaptiveSupported = modelData.adaptiveSupported ?? false; + this._awarenessSupported = modelData.awarenessSupported ?? false; + this._pressSpeedDurationSupported = modelData.pressSpeedDurationSupported ?? false; + this._volumeSwipeSupported = modelData.volumeSwipeSupported ?? false; + this._longPressCycleSupported = modelData.longPressCycleSupported ?? false; + this._toneVolumeSupported = modelData.toneVolumeSupported ?? false; + + this._specificFeatureAckRecieved = false; + this._callbacks = callbacks; + + this._ancMode = ANCMode.ANC_OFF; + this._awarenessMode = AwarenessMode.OFF; + this._bud1State = EarDetection.IN_CASE; + this._bud2State = EarDetection.IN_CASE; + + this.startSocket(); + } + + async postConnectInitialization() { + await this.sendMessage(PacketConstants.HANDSHAKE); + this._log.info('Handshake sent'); + + if (this._awarenessSupported) { + await this.sendMessage(PacketConstants.SET_SPECIFIC_FEATURES); + this._log.info('Specific features sent'); + } + + await new Promise(resolve => { + this._delayReadTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + resolve(); + this._delayReadTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + }); + + await this.sendMessage(PacketConstants.REQUEST_NOTIFICATIONS); + this._log.info('Request notifications sent'); + } + + processData(data) { + const isPrefix = (arr, prefix) => + prefix.every((val, i) => arr[i] === val); + + if (isPrefix(data, PacketConstants.HANDSHAKE_ACK)) { + this._log.info('Received HANDSHAKE_ACK'); + return; + } + + if (isPrefix(data, PacketConstants.FEATURES_ACK)) { + this._specificFeatureAckRecieved = true; + this._log.info('Received FEATURES_ACK'); + return; + } + + if (data.length > 11 && + isPrefix(data, PacketConstants.BATTERY_STATUS_NOTIFICATION)) { + this._log.info('Received Battery notification'); + this._parseBatteryStatus(data); + return; + } + + if (isPrefix(data, PacketConstants.EAR_DETECTION_PREFIX)) { + this._log.info('Received Ear detection status update'); + this._parseEarDetection(data); + return; + } + + if (this._ancSupported && data.length === 11 && + isPrefix(data, PacketConstants.NOISE_CANCELLATION_HEADER)) { + this._log.info('Received ANC mode update'); + this._parseAncStatus(data); + return; + } + + if (this._adaptiveSupported && data.length === 11 && + isPrefix(data, PacketConstants.ADAPTIVE_CONTROL_HEADER)) { + this._log.info('Received Adative mode level update'); + this._parseAdaptiveLevel(data); + return; + } + + if (this._awarenessSupported && data.length === 11 && + isPrefix(data, PacketConstants.CONVERSATION_AWARENESS_HEADER)) { + this._log.info('Received Conversation mode update'); + this._parseAwarenessStatus(data); + return; + } + + if (this._awarenessSupported && data.length === 10 && + isPrefix(data, PacketConstants.CONVERSATION_AWARENESS_DATA)) { + this._log.info('Received Conversation mode data update'); + this._parseAwarenessData(data); + } + + if (this._pressSpeedDurationSupported && data.length === 11 && + isPrefix(data, PacketConstants.PRESS_SPEED_HEADER)) { + this._log.info('Received Press speed mode update'); + this._parsePressSpeed(data); + } + + if (this._pressSpeedDurationSupported && data.length === 11 && + isPrefix(data, PacketConstants.PRESS_AND_HOLD_DURATION_HEADER)) { + this._log.info('Received Press and hold duration mode update'); + this._parsePressDuration(data); + } + + if (this._volumeSwipeSupported && data.length === 11 && + isPrefix(data, PacketConstants.VOL_SWIPE_MODE_HEADER)) { + this._log.info('Received Volume slider status update'); + this._parsePressSpeed(data); + } + + if (this._volumeSwipeSupported && data.length === 11 && + isPrefix(data, PacketConstants.VOL_SWIPE_LENGHT_HEADER)) { + this._log.info('Received Volume slider length update'); + this._parseVolSwipeLength(data); + } + if (this._toneVolumeSupported && data.length === 11 && + isPrefix(data, PacketConstants.NOTI_VOLUME_HEADER)) { + this._log.info('Received Tone notification volume update'); + this._parseNotificationToneMode(data); + } + } + + + _parseBatteryStatus(data) { + const count = data[6]; + if (count < 1 || count > 3) + return; + + let start = 7; + const props = {}; + + for (let i = 0; i < count; i++) { + const type = data[start]; + const levelVal = data[start + 2]; + let level, statusStr; + + if (levelVal === 0xFF) { + level = 0; + statusStr = 'disconnected'; + } else { + level = Math.max(0, Math.min(levelVal, 100)); + const statusByte = data[start + 3]; + + const charging = (statusByte & BatteryChargingStatus.CHARGING) !== 0; + statusStr = charging ? 'charging' : 'discharging'; + } + + let batteryIndex = null; + if (type === BatteryType.SINGLE || type === BatteryType.LEFT) + batteryIndex = 1; + else if (type === BatteryType.RIGHT) + batteryIndex = 2; + else if (type === BatteryType.CASE) + batteryIndex = 3; + + if (batteryIndex !== null) { + props[`battery${batteryIndex}Level`] = level; + props[`battery${batteryIndex}Status`] = statusStr; + } + + start += 5; + } + + if (this._callbacks?.updateBatteryProps) + this._callbacks.updateBatteryProps(props); + } + + _parseAncStatus(data) { + const ancModeByte = data[7]; + + if (!isValidByte(ancModeByte, ANCMode)) + return; + + if (this._ancMode !== ancModeByte) { + this._ancMode = ancModeByte; + + if (this._callbacks?.updateAncMode) + this._callbacks.updateAncMode(ancModeByte); + } + } + + _parseAdaptiveLevel(data) { + const adaptiveLevelByte = data[7]; + + if (adaptiveLevelByte < 0 && adaptiveLevelByte > 100) + return; + + if (this._adaptiveLevelByte !== adaptiveLevelByte) { + this._adaptiveLevelByte = adaptiveLevelByte; + + if (this._callbacks?.updateAdaptiveLevel) + this._callbacks.updateAdaptiveLevel(adaptiveLevelByte); + } + } + + _parseAwarenessStatus(data) { + const awarenessModeByte = data[7]; + + if (!isValidByte(awarenessModeByte, AwarenessMode)) + return; + + if (this._awarenessMode !== awarenessModeByte) { + this._awarenessMode = awarenessModeByte; + + if (this._callbacks?.updateAwarenessMode) + this._callbacks.updateAwarenessMode(awarenessModeByte); + } + } + + _parseEarDetection(data) { + const bud1Raw = data[6]; + const bud2Raw = data[7]; + + const validValues = Object.values(EarDetection); + if (!validValues.includes(bud1Raw) || !validValues.includes(bud2Raw)) + return; + + if (this._awarenessSupported && this._specificFeatureAckRecieved) { + const previousStateOutEar = this._bud1State !== EarDetection.IN_EAR && + this._bud2State !== EarDetection.IN_EAR; + const newStateInEar = bud1Raw === EarDetection.IN_EAR || + bud2Raw !== EarDetection.IN_EAR; + + if (previousStateOutEar && newStateInEar) { + this.sendMessage(PacketConstants.SET_SPECIFIC_FEATURES); + this._log.info('Specific features sent'); + } + } + + if (bud1Raw !== this._bud1State || bud2Raw !== this._bud2State) { + this._bud1State = bud1Raw; + this._bud2State = bud2Raw; + + if (this._callbacks?.updateInEarStatus) + this._callbacks.updateInEarStatus(bud1Raw, bud2Raw); + } + } + + _parseAwarenessData(data) { + const level = data[9]; + if (level >= 1 && level <= 9) { + const attenuated = level <= 2; + + if (this._callbacks?.updateAwarenessData) + this._callbacks.updateAwarenessData(attenuated); + } + } + + _parseNotificationToneMode(data) { + const value = data[9]; + if (value < 15 || value > 100) + return; + + const level = Math.round((value - 15) / (100 - 15) * 100); + if (this._callbacks?.updateNotificationToneMode) + this._callbacks.updateNotificationToneMode(level); + } + + _parseVolSwipeMode(data) { + const mode = data[9]; + if (isValidByte(mode, VolSwipeMode)) { + const state = mode === VolSwipeMode.ON; + if (this._callbacks?.updateVolSwipeMode) + this._callbacks.updateVolSwipeMode(state); + } + } + + _parseVolSwipeLength(data) { + const duration = data[9]; + if (isValidByte(duration, VolSwipeLength)) { + if (this._callbacks?.updateVolSwipeLength) + this._callbacks.updateVolSwipeLength(duration); + } + } + + _parsePressSpeed(data) { + const speed = data[9]; + if (isValidByte(speed, PressSpeedMode)) { + if (this._callbacks?.updatePressSpeed) + this._callbacks.updatePressSpeed(speed); + } + } + + _parsePressDuration(data) { + const duration = data[9]; + if (isValidByte(duration, PressDurationMode)) { + if (this._callbacks?.updatePressDuration) + this._callbacks.updatePressDuration(duration); + } + } + + setAncMode(mode) { + if (mode === ANCMode.ANC_OFF) + this.sendMessage(PacketConstants.NOISE_CANCELLATION_OFF); + else if (mode === ANCMode.ANC_ON) + this.sendMessage(PacketConstants.NOISE_CANCELLATION_ON); + else if (mode === ANCMode.TRANSPARENCY) + this.sendMessage(PacketConstants.NOISE_CANCELLATION_TRANSPARENCY); + else if (mode === ANCMode.ADAPTIVE) + this.sendMessage(PacketConstants.NOISE_CANCELLATION_ADAPTIVE); + } + + setAdaptiveLevel(level) { + const levelPacket = Uint8Array.from([ + ...PacketConstants.ADAPTIVE_CONTROL_HEADER, + level, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(levelPacket); + } + + setAwarenessMode(mode) { + if (mode === AwarenessMode.ON) + this.sendMessage(PacketConstants.CONVERSATION_AWARENESS_ON); + else if (mode === AwarenessMode.OFF) + this.sendMessage(PacketConstants.CONVERSATION_AWARENESS_OFF); + + if (this._callbacks?.updateAwarenessMode) + this._callbacks.updateAwarenessMode(mode); + } + + setListMode(lisMode) { + const listModeByte = lisMode ? 0x01 : 0x02; + const lisModePacket = Uint8Array.from([ + ...PacketConstants.LISTENING_MODE_HEADER, + listModeByte, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(lisModePacket); + } + + setLongpressCycle(cyclicValue) { + const cyclicPacket = Uint8Array.from([ + ...PacketConstants.LONGPRESS_CYCLE_HEADER, + cyclicValue, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(cyclicPacket); + } + + setNotiVolume(level) { + const value = Math.round(level * (100 - 15) / 100 + 15); + + const notiVolumePacket = Uint8Array.from([ + ...PacketConstants.NOTI_VOLUME_HEADER, + value, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(notiVolumePacket); + } + + setSwipeMode(state) { + const mode = state ? VolSwipeMode.ON : VolSwipeMode.OFF; + const swipeModePacket = Uint8Array.from([ + ...PacketConstants.VOL_SWIPE_MODE_HEADER, + mode, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(swipeModePacket); + } + + setSwipeLength(len) { + const swipeLengthPacket = Uint8Array.from([ + ...PacketConstants.VOL_SWIPE_LENGHT_HEADER, + len, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(swipeLengthPacket); + } + + + setPressSpeed(speed) { + const pressSpeedPacket = Uint8Array.from([ + ...PacketConstants.PRESS_SPEED_HEADER, + speed, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(pressSpeedPacket); + } + + setPressDur(dur) { + const pressDurPacket = Uint8Array.from([ + ...PacketConstants.PRESS_AND_HOLD_DURATION_HEADER, + dur, + ...PacketConstants.SUFFIX, + ]); + this.sendMessage(pressDurPacket); + } + + destroy() { + if (this._delayReadTimeoutId) + GLib.source_remove(this._delayReadTimeoutId); + this._delayReadTimeoutId = null; + super.destroy(); + } +}); + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPods1stGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPods1stGen.js new file mode 100644 index 0000000000..e61489c77c --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPods1stGen.js @@ -0,0 +1,14 @@ +'use strict'; + +// AirPods 1st Gen +export default { + key: '2002', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + budsIcon: 'earbuds-stem3', + case: 'case-narrow', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPods2ndGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPods2ndGen.js new file mode 100644 index 0000000000..146dfaca06 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPods2ndGen.js @@ -0,0 +1,14 @@ +'use strict'; + +// AirPods 2nd Gen +export default { + key: '200F', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + budsIcon: 'earbuds-stem3', + case: 'case-narrow', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPods3rdGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPods3rdGen.js new file mode 100644 index 0000000000..a56fb53aec --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPods3rdGen.js @@ -0,0 +1,15 @@ +'use strict'; + +// AirPods 3rd Gen +export default { + key: '2013', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGen.js new file mode 100644 index 0000000000..a7cc6d8b87 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGen.js @@ -0,0 +1,15 @@ +'use strict'; + +// AirPods 4th Gen +export default { + key: '2019', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGenANC.js b/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGenANC.js new file mode 100644 index 0000000000..63bd3c604d --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPods4thGenANC.js @@ -0,0 +1,16 @@ +'use strict'; + +// AirPods 4th Gen with ANC +export default { + key: '201B', + batteryType: 3, + ancSupported: true, + adaptiveSupported: true, + awarenessSupported: true, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax.js new file mode 100644 index 0000000000..27c60e964d --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax.js @@ -0,0 +1,16 @@ +'use strict'; + +// AirPods Max +export default { + key: '200A', + batteryType: 1, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax2.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax2.js new file mode 100644 index 0000000000..8958a36db3 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMax2.js @@ -0,0 +1,16 @@ +'use strict'; + +// AirPods Max2 +export default { + key: '202D', + batteryType: 1, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsMaxUsbC.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMaxUsbC.js new file mode 100644 index 0000000000..9625734170 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsMaxUsbC.js @@ -0,0 +1,16 @@ +'use strict'; + +// AirPods Max USB-C +export default { + key: '201F', + batteryType: 1, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro.js new file mode 100644 index 0000000000..8a3c99c8d7 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro.js @@ -0,0 +1,16 @@ +'use strict'; + +// AirPods Pro +export default { + key: '200E', + batteryType: 3, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGen.js new file mode 100644 index 0000000000..d404494a10 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGen.js @@ -0,0 +1,17 @@ +'use strict'; + +// AirPods Pro 2nd Gen +export default { + key: '2014', + batteryType: 3, + ancSupported: true, + adaptiveSupported: true, + awarenessSupported: true, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + longPressCycleSupported: true, + enableTurnOffListeningMode: true, + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGenUsbC.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGenUsbC.js new file mode 100644 index 0000000000..04f0cbb40f --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGenUsbC.js @@ -0,0 +1,18 @@ +'use strict'; + +// AirPods Pro 2nd Gen USB-C +export default { + key: '2024', + batteryType: 3, + ancSupported: true, + adaptiveSupported: true, + awarenessSupported: true, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + volumeSwipeSupported: true, + longPressCycleSupported: true, + enableTurnOffListeningMode: true, + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro3rdGen.js b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro3rdGen.js new file mode 100644 index 0000000000..d7bd37579c --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/AirPodsPro3rdGen.js @@ -0,0 +1,18 @@ +'use strict'; + +// AirPods Pro 3rd Gen +export default { + key: '2027', + batteryType: 3, + ancSupported: true, + adaptiveSupported: true, + awarenessSupported: true, + toneVolumeSupported: true, + pressSpeedDurationSupported: true, + volumeSwipeSupported: true, + longPressCycleSupported: true, + enableTurnOffListeningMode: true, + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsFitPro.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsFitPro.js new file mode 100644 index 0000000000..66578d6bf3 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsFitPro.js @@ -0,0 +1,14 @@ +'use strict'; + +// Beats Fit Pro +export default { + key: '2012', + batteryType: 3, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + budsIcon: 'earbuds-wingtip', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsFlex.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsFlex.js new file mode 100644 index 0000000000..a0bca5defb --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsFlex.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Flex +export default { + key: '2010', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds-neckband', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo3.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo3.js new file mode 100644 index 0000000000..d9235441e0 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo3.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Solo 3 +export default { + key: '2006', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo4.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo4.js new file mode 100644 index 0000000000..e87b0dfed0 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsSolo4.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Solo 4 +export default { + key: '2025', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloBuds.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloBuds.js new file mode 100644 index 0000000000..8a5678c278 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloBuds.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Solo Buds +export default { + key: '2026', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloPro.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloPro.js new file mode 100644 index 0000000000..5ccc9bbecf --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsSoloPro.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Solo Pro +export default { + key: '200C', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsStudio3.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudio3.js new file mode 100644 index 0000000000..781c29b3bf --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudio3.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Studio 3 +export default { + key: '2009', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBuds.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBuds.js new file mode 100644 index 0000000000..db49eb18f0 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBuds.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Studio Buds +export default { + key: '2011', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBudsPlus.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBudsPlus.js new file mode 100644 index 0000000000..6f0422c939 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBudsPlus.js @@ -0,0 +1,14 @@ +'use strict'; + +// Beats Studio Buds Plus +export default { + key: '2016', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds', + case: 'case-oval', + +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioPro.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioPro.js new file mode 100644 index 0000000000..431745169d --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsStudioPro.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats Studio Pro +export default { + key: '2017', + batteryType: 1, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'headphone1', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/BeatsX.js b/bbm/lib/devices/airpods/deviceConfigs/BeatsX.js new file mode 100644 index 0000000000..1f8fa10c96 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/BeatsX.js @@ -0,0 +1,13 @@ +'use strict'; + +// Beats X +export default { + key: '2005', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds-neckband', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/Powerbeats3.js b/bbm/lib/devices/airpods/deviceConfigs/Powerbeats3.js new file mode 100644 index 0000000000..36cc399ed5 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/Powerbeats3.js @@ -0,0 +1,13 @@ +'use strict'; + +// Powerbeats 3 +export default { + key: '2003', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds-wingtip2', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/Powerbeats4.js b/bbm/lib/devices/airpods/deviceConfigs/Powerbeats4.js new file mode 100644 index 0000000000..debdae6c88 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/Powerbeats4.js @@ -0,0 +1,13 @@ +'use strict'; + +// Powerbeats 4 +export default { + key: '200D', + batteryType: 1, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds-wingtip2', + case: null, +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsFit.js b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsFit.js new file mode 100644 index 0000000000..f552967666 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsFit.js @@ -0,0 +1,13 @@ +'use strict'; + +// Powerbeats Fit +export default { + key: '202F', + batteryType: 3, + ancSupported: true, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + budsIcon: 'earbuds-wingtip', + case: 'case-oval-short', +}; diff --git a/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro.js b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro.js new file mode 100644 index 0000000000..9b8aff89e8 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro.js @@ -0,0 +1,13 @@ +'use strict'; + +// Powerbeats Pro +export default { + key: '200B', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + budsIcon: 'earbuds-wingtip2', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro2.js b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro2.js new file mode 100644 index 0000000000..9cbd2a5353 --- /dev/null +++ b/bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro2.js @@ -0,0 +1,14 @@ +'use strict'; + +// Powerbeats Pro 2 +export default { + key: '201D', + batteryType: 3, + ancSupported: false, + adaptiveSupported: false, + awarenessSupported: false, + toneVolumeSupported: true, + budsIcon: 'earbuds-wingtip2', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/boseBuds/boseBudsConfig.js b/bbm/lib/devices/boseBuds/boseBudsConfig.js new file mode 100644 index 0000000000..a38b79ee53 --- /dev/null +++ b/bbm/lib/devices/boseBuds/boseBudsConfig.js @@ -0,0 +1,129 @@ +'use strict'; + +import QC35 from './deviceConfigs/QC35.js'; +import QC35SE from './deviceConfigs/QC35SE.js'; +import QCEarbuds2 from './deviceConfigs/QCEarbuds2.js'; +import QCUltra2Earbuds from './deviceConfigs/QCUltra2Earbuds.js'; +import QCUltra2Headphones from './deviceConfigs/QCUltra2Headphones.js'; +import QCUltraEarbuds from './deviceConfigs/QCUltraEarbuds.js'; +import QCUltraHeadphones from './deviceConfigs/QCUltraHeadphones.js'; + +export const BoseBudsModelList = [ + QC35, + QC35SE, + QCEarbuds2, + QCUltra2Earbuds, + QCUltra2Headphones, + QCUltraEarbuds, + QCUltraHeadphones, +]; + +export const Operator = { + SET: 0x00, + GET: 0x01, + SETGET: 0x02, + STATUS: 0x03, + ERROR: 0x04, + START: 0x05, + RESULT: 0x06, + PROCESSING: 0x07, +}; + +export const SourceType = { + NONE: 0, + BLUETOOTH: 1, + AUXILIARY: 2, +}; + +export const CommandType = { + INIT: 0x0001, + FIRMWARE: 0x0005, + SERIAL: 0x0007, + PRODUCT_NAME: 0x0102, + VOICE_PROMPTS: 0x0103, + AUTO_POWER_OFF_TIME: 0x0104, + CNC: 0x0105, + ANR: 0x0106, + EQ: 0x0107, + BUTTONS: 0x0109, + DUALCONNECTION: 0x010A, + SIDETONE: 0x010B, + INEAR_SETTINGS: 0x0110, + AUTO_PAUSE: 0x0118, + AUTO_ANSWER: 0x011B, + AUTO_TRANSP: 0x011D, + BATTERY: 0x0202, + CHARGING_STATE: 0x0205, + INEAR_STATE: 0x0209, + CONNECT_DEVICE: 0x0401, + DISCONNECT_DEVICE: 0x0402, + REMOVE_DEVICE: 0x0403, + GET_ALL_DEVICES: 0x0404, + DEVICE_INFO: 0x0405, + PAIRING_MODE: 0x0408, + GET_OWN_DEVICE_ID: 0x0409, + DEVICE_ROUTING: 0x040C, + SOURCE: 0x0501, + SPATIAL_AUDIO: 0x050F, + POWER: 0x0704, + NOTIFICATION_BY_BLOCK: 0x0902, + AUDIOMODE_GETALL: 0x1F01, + AUDIOMODE_CAPABILTY: 0x1F02, + AUDIOMODE_CURRENT: 0x1F03, + AUDIOMODE_DEFAULT: 0x1F04, + AUDIOMODE_RESTORE: 0x1F05, + AUDIOMODE_CONFIG: 0x1F06, + AUDIOMODE_FAV: 0x1F08, + AUDIO_SETTNGS: 0x1F0A, +}; + +export const BudId = { + Single: 0x00, + Right: 0x01, + Left: 0x02, + Case: 0x03, +}; + +export const AudioModes = { + 1: 'quiet', + 2: 'aware', + 34: 'immersion', + 36: 'cinema', + 7: 'commute', + 13: 'focus', + 10: 'home', + 12: 'music', + 8: 'outdoor', + 14: 'relax', + 20: 'run', + 21: 'walk', + 11: 'work', + 9: 'workout', +}; + +export const VoicePrompt = { + 0x00: 'English (UK)', + 0x01: 'English (US)', + 0x02: 'Français', + 0x03: 'Italiano', + 0x04: 'Deutsch', + 0x05: 'Español (EU)', + 0x06: 'Español (MX)', + 0x07: 'Português', + 0x08: '普通话 (Mandarin)', + 0x09: '한국어 (Korean)', + 0x0A: 'Русский (Russian)', + 0x0B: 'Polski', + 0x0C: 'עִברִית (Hebrew)', + 0x0D: 'Türk', + 0x0E: 'Nederlands', + 0x0F: '日本語 (Japanese)', + 0x10: '廣東話 (Cantonese)', + 0x11: 'العربية (Arabic)', + 0x12: 'Svensk', + 0x13: 'Dansk', + 0x14: 'Norsk', + 0x15: 'Suomen kieli (Finnish)', + 0x16: 'हिंदी (Hindi)', +}; + diff --git a/bbm/lib/devices/boseBuds/boseBudsDevice.js b/bbm/lib/devices/boseBuds/boseBudsDevice.js new file mode 100644 index 0000000000..cbf522b312 --- /dev/null +++ b/bbm/lib/devices/boseBuds/boseBudsDevice.js @@ -0,0 +1,1592 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, isArrayEqual, + SppUUidType, SppUUid +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import {BoseBudsSocket} from './boseBudsSocket.js'; +import {BoseBudsModelList, AudioModes} from './boseBudsConfig.js'; +import {BtDeviceState, DeviceManagementAction} from '../commonEmuns.js'; + +export const DeviceTypeBoseBuds = 'boseBuds'; +const BoseBudsUUID = '00000000-deca-fade-deca-deafdecacaff'; +export function isBoseBuds(bluezDeviceProxy, uuids) { + const bluezProps = ['Modalias']; + let supported = 'no'; + + if (!uuids.includes(BoseBudsUUID) || !uuids.includes(SppUUid)) + return {supported, bluezProps}; + + const modalias = bluezDeviceProxy.Modalias; + if (!modalias) { + supported = 'pending'; + return {supported, bluezProps}; + } + + const regex = /v009Ep([0-9A-Fa-f]{4})d/; + const match = modalias.match(regex); + if (!match) + return {supported, bluezProps}; + + const modelId = match[1].toUpperCase(); + if (BoseBudsModelList.some(m => m.id === modelId)) + supported = 'yes'; + + return {supported, bluezProps}; +} + +export const BoseBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BoseBudsDevice', +}, class BoseBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + + const identifier = getDeviceIdentifier(devicePath); + const tag = `BoseBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- BoseBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._ignoreGsettingsChange = false; + this._inEarSettings = false; + + this._config = createConfig(); + this._props = createProperties(); + this._modelData = null; + this._fwVersion = ''; + this._serialNo = ''; + this._deviceInfo = []; + this._pendingBTOperations = new Map(); + this._modeCount = 10; + + this._callbacks = { + updateFirmware: this.updateFirmware.bind(this), + updateSerial: this.updateSerial.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateInEarState: this.updateInEarState.bind(this), + updateEq: this.updateEq.bind(this), + updateAnr: this.updateAnr.bind(this), + updateAudioModeCurrent: this.updateAudioModeCurrent.bind(this), + updateAudioModeRestore: this.updateAudioModeRestore.bind(this), + updateAudioModeFavorites: this.updateAudioModeFavorites.bind(this), + updateCnc: this.updateCnc.bind(this), + updateSpatialAudio: this.updateSpatialAudio.bind(this), + updateDualConnection: this.updateDualConnection.bind(this), + updateSideTone: this.updateSideTone.bind(this), + updateInEarSettings: this.updateInEarSettings.bind(this), + updateAutoAnswer: this.updateAutoAnswer.bind(this), + updateAutoPause: this.updateAutoPause.bind(this), + updateAutoTransparency: this.updateAutoTransparency.bind(this), + updateAutoPowerOffTimer: this.updateAutoPowerOffTimer.bind(this), + updateVoicePrompt: this.updateVoicePrompt.bind(this), + updateActionButton: this.updateActionButton.bind(this), + updateBTDeviceList: this.updateBTDeviceList.bind(this), + updateBTDeviceInfo: this.updateBTDeviceInfo.bind(this), + updatePairingMode: this.updatePairingMode.bind(this), + updateConnectError: this.updateConnectError.bind(this), + updateConnectStatus: this.updateConnectStatus.bind(this), + updateDisconnectError: this.updateDisconnectError.bind(this), + updateDisconnectStatus: this.updateDisconnectStatus.bind(this), + updateRemoveBTDeviceError: this.updateRemoveBTDeviceError.bind(this), + updateRemoveBTDeviceStatus: this.updateRemoveBTDeviceStatus.bind(this), + updateRoutingStatus: this.updateRoutingStatus.bind(this), + updateOwnDeviceId: this.updateOwnDeviceId.bind(this), + }; + + const bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const modalias = bluezDeviceProxy.Modalias; + + const regex = /v009Ep([0-9A-Fa-f]{4})d/; + const match = modalias.match(regex); + if (!match) { + this._log.info(`No device configuration found for modalias: ${modalias}`); + return; + } + + const modelId = match[1].toUpperCase(); + this._modelData = BoseBudsModelList.find(m => m.id === modelId); + if (!this._modelData) { + this._log.info(`No device configuration found for modalias: ${modalias}`); + return; + } + + this._log.info(`Configuration: ${JSON.stringify(this._modelData, null, 2)}`); + + this._commonIcon = this._modelData.budsIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.showSettingsButton = true; + + if (!this._modelData.batterySingle) + this._caseIcon = `${this._modelData.case}`; + + this._createDefaultSettings(); + + const devicesList = this._settings.get_strv('bose-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'bose-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorBoseBudsListGsettings(); + this._updateDeviceInfo(); + this._updateIcons(); + if (this._modelData.audioModes) + this._setupAudioModeToggle(); + else if (this._modelData.anr) + this._setupAnrToggle(); + + if (this._modelData.audioModes?.showNCInterface) + this._addCNC(); + + if (this._modelData.audioModes?.spatialMode) + this._addSpatialMode(); + + const profile = {type: SppUUidType, uuid: SppUUid}; + + this._boseBudsSocket = new BoseBudsSocket( + this._devicePath, + profileManager, + profile, + this._modelData, + this._callbacks + ); + } + + _createModesArray() { + const audioModes = this._modelData.audioModes; + const modes = []; + + for (const preset of Object.values(audioModes.presets)) { + modes[preset.index] = { + ...audioModes.defaultConfig, + ...preset, + }; + } + + for (let index = 0; index < audioModes.totalModes; index++) { + if (!modes[index]) { + modes[index] = { + ...audioModes.defaultConfig, + index, + }; + } + } + + return modes; + } + + _createDefaultGestureSettings() { + const gestures = []; + + for (const button of Object.values(this._modelData.gestureOptions.buttons)) { + for (const gesture of Object.values(button.gestures)) { + const [, actionByte] = Object.entries(gesture.actions)[0]; + + gestures.push({ + id: button.id, + gesture: gesture.byte, + action: actionByte, + }); + } + } + + return gestures; + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + id: this._modelData.id, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': this._fwVersion, + 'serial': this._serialNo, + + ...!this._modelData.batterySingle && { + 'case': this._caseIcon, + }, + + ...this._modelData.audioModes && { + 'modes': this._createModesArray(), + 'current-mode': 0, + 'restore-mode': false, + }, + + ...this._modelData.eq && { + 'eq-preset': 'flat', + }, + + ...this._modelData.eq?.bands !== undefined && { + 'eq-custom': new Array(this._modelData.eq.bands.length).fill(0), + }, + + ...this._modelData.dualConnection && { + 'multipoint': false, + 'dev-mgmt': [], + 'pairing-mode': false, + 'dev-mgmt-action': {seq: 0, action: 0, id: ''}, + 'active-dev': '', + 'own-dev': '', + }, + + ...this._modelData.sideTone && { + 'side-tone': 0, + }, + + ...this._modelData.inEarSettings && { + 'in-ear': false, + 'wear-detection-mode': 1, + }, + + ...this._modelData.autoAnswer && { + 'auto-answer': false, + }, + + ...this._modelData.autoPause && { + 'auto-pause': false, + }, + + ...this._modelData.autoTransparency && { + 'auto-transp': false, + }, + + ...this._modelData.automaticPowerOffTimer && { + 'auto-power': this._modelData.automaticPowerOffTimer[0], + }, + + ...this._modelData.voicePrompt && { + 'voice-enabled': false, + 'voice-prompt': 0xFF, + 'supported-voice': 0xFFFFFFFF, + 'batt-voice': 0, + 'vobat-sup': false, + 'vobat-en': false, + }, + + ...this._modelData.gestureOptions && { + 'gestures': this._createDefaultGestureSettings(), + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('bose-buds-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('bose-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (!this._modelData.batterySingle) + this._caseIcon = this._settingsItems['case']; + + if (this._modelData.audioModes) { + this._audioModes = this._settingsItems['modes']; + this._currentAudioMode = this._settingsItems['current-mode']; + this._restoreAudioMode = this._settingsItems['restore-mode']; + } + + if (this._modelData.eq) + this._presetEq = this._settingsItems['eq-preset']; + + if (this._modelData.eq?.bands !== undefined) + this._customEq = this._settingsItems['eq-custom']; + + if (this._modelData.dualConnection) { + this._multipoint = this._settingsItems['multipoint']; + this._pairingMode = this._settingsItems['pairing-mode']; + this._devMgmtAction = this._settingsItems['dev-mgmt-action']; + this._activeDevice = this._settingsItems['active-dev']; + this._ownDevice = this._settingsItems['own-dev']; + } + + if (this._modelData.sideTone) + this._sideTone = this._settingsItems['side-tone']; + + if (this._modelData.inEarSettings) { + this._inEarSettings = this._settingsItems['in-ear']; + this._wearDetectionMode = this._settingsItems['wear-detection-mode']; + } + + if (this._modelData.autoAnswer) + this._autoAnswer = this._settingsItems['auto-answer']; + + if (this._modelData.autoPause) + this._autoPause = this._settingsItems['auto-pause']; + + if (this._modelData.autoTransparency) + this._autoTransparency = this._settingsItems['auto-transp']; + + if (this._modelData.automaticPowerOffTimer) + this._autoPowerOffTimer = this._settingsItems['auto-power']; + + if (this._modelData.voicePrompt) { + this._voiceEnabled = this._settingsItems['voice-enabled']; + this._voicePrompt = this._settingsItems['voice-prompt']; + this._supportedVoice = this._settingsItems['supported-voice']; + this._voiceBatSupported = this._settingsItems['vobat-sup']; + this._voiceBatEnabled = this._settingsItems['vobat-en']; + } + + if (this._modelData.gestureOptions) + this._gestures = this._settingsItems['gestures']; + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('bose-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (!this._modelData.batterySingle) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + if (this._modelData.audioModes) { + const modes = this._settingsItems['modes']; + let favoritesChanged = false; + let uiModesChanged = false; + const favorites = []; + + for (const mode of modes) { + const isModeEqual = (a, b) => Object.keys(a).every(key => { + if (key === 'ui' || key === 'fav') + return true; + + return a[key] === b[key]; + }); + + const isFavoriteEqual = (a, b) => a.added === b.added && a.fav === b.fav; + const isUiEqual = (a, b) => a.added === b.added && a.ui === b.ui; + + + const current = this._audioModes.find(m => m.index === mode.index); + + if (!current || !isModeEqual(current, mode)) + this._setAudioMode(mode); + + if (current && !isFavoriteEqual(current, mode)) + favoritesChanged = true; + + if (current && !isUiEqual(current, mode)) + uiModesChanged = true; + + if (mode.added && mode.fav) + favorites.push(mode.index); + } + + if (favoritesChanged) + this._setAudioModeFavorites(favorites); + + this._audioModes = modes.map(mode => ({...mode})); + + if (uiModesChanged) + this._setupAudioModeToggle(); + + const currentAudioMode = this._settingsItems['current-mode']; + if (this._currentAudioMode !== currentAudioMode) { + this._currentAudioMode = currentAudioMode; + this._setCurrentAudioMode(currentAudioMode); + this.updateAudioModeCurrent(currentAudioMode, true); + } + + const restoreAudioMode = this._settingsItems['restore-mode']; + if (this._restoreAudioMode !== restoreAudioMode) { + this._restoreAudioMode = restoreAudioMode; + this._setRestoreAudioMode(restoreAudioMode); + } + } + + if (this._modelData.eq?.bands !== undefined) { + const eqCustom = this._settingsItems['eq-custom']; + if (!this._customEq || !isArrayEqual(eqCustom, this._customEq)) { + const oldGains = this._customEq; + const newGains = eqCustom; + + this._customEq = eqCustom; + this._setCustomEq(oldGains, newGains); + } + } + + if (this._modelData.dualConnection) { + const multipoint = this._settingsItems['multipoint']; + if (this._multipoint !== multipoint) { + this._multipoint = multipoint; + this._setMultipoint(multipoint); + } + + const pairingMode = this._settingsItems['pairing-mode']; + if (this._pairingMode !== pairingMode) { + this._pairingMode = pairingMode; + this._setPairingMode(pairingMode); + } + + const devMgmtAction = this._settingsItems['dev-mgmt-action']; + if (this._devMgmtAction.seq !== devMgmtAction.seq) { + this._devMgmtAction = devMgmtAction; + + switch (devMgmtAction.action) { + case DeviceManagementAction.Connect: + this._connectBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Disconnect: + this._disconnectBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Remove: + this._removeBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Routing: + this._setRoutingBTDevice(devMgmtAction.id); + break; + } + } + } + + if (this._modelData.sideTone) { + const sideTone = this._settingsItems['side-tone']; + if (this._sideTone !== sideTone) { + this._sideTone = sideTone; + this._setSideTone(sideTone); + } + } + + if (this._modelData.inEarSettings) { + const inEarSettings = this._settingsItems['in-ear']; + if (this._inEarSettings !== inEarSettings) { + this._inEarSettings = inEarSettings; + this._setInEarSettings(inEarSettings); + } + + const wearDetectionMode = this._settingsItems['wear-detection-mode']; + if (this._wearDetectionMode !== wearDetectionMode) { + this._wearDetectionMode = wearDetectionMode; + this._configureMediaController(); + } + } + + if (this._modelData.autoAnswer) { + const autoAnswer = this._settingsItems['auto-answer']; + if (this._autoAnswer !== autoAnswer) { + this._autoAnswer = autoAnswer; + this._setAutoAnswer(autoAnswer); + } + } + + if (this._modelData.autoPause) { + const autoPause = this._settingsItems['auto-pause']; + if (this._autoPause !== autoPause) { + this._autoPause = autoPause; + this._setAutoPause(autoPause); + } + } + + if (this._modelData.autoTransparency) { + const autoTransparency = this._settingsItems['auto-transp']; + if (this._autoTransparency !== autoTransparency) { + this._autoTransparency = autoTransparency; + this._setAutoTransparency(autoTransparency); + } + } + + if (this._modelData.automaticPowerOffTimer) { + const minutes = this._settingsItems['auto-power']; + if (this._autoPowerOffTimer !== minutes) { + this._autoPowerOffTimer = minutes; + this._setAutoPowerOffTimer(minutes); + } + } + + if (this._modelData.voicePrompt) { + let update = false; + const voiceEnabled = this._settingsItems['voice-enabled']; + if (this._voiceEnabled !== voiceEnabled) { + this._voiceEnabled = voiceEnabled; + update = true; + } + + const voicePrompt = this._settingsItems['voice-prompt']; + if (this._voicePrompt !== voicePrompt) { + this._voicePrompt = voicePrompt; + update = true; + } + + const batEnabled = this._settingsItems['vobat-en']; + if (this._voiceBatEnabled !== batEnabled) { + this._voiceBatEnabled = batEnabled; + update = true; + } + + if (update) + this._setVoicePrompt(); + } + + if (this._modelData.gestureOptions) { + const gestures = this._settingsItems['gestures'] ?? []; + + const getGestureKey = g => g.id << 8 | g.gesture; + + const oldGestures = new Map( + (this._gestures ?? []).map(g => [getGestureKey(g), g]) + ); + + for (const gesture of gestures) { + const old = oldGestures.get(getGestureKey(gesture)); + + if (!old || old.action !== gesture.action) + this._setGesture(gesture); + } + + this._gestures = gestures; + } + } + + _monitorBoseBudsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::bose-buds-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('bose-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('bose-buds-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _configureMediaController() { + // MediaController not available in daemon context + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + this._config.battery1ShowOnDisconnect = true; + + if (this._modelData.batterySingle) { + this._config.battery1Icon = this._commonIcon; + } else { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery2ShowOnDisconnect = true; + this._config.battery3Icon = this._caseIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + _updateDeviceInfo() { + this._settingsItems['id'] = this._modelData.id; + this._settingsItems['alias'] = this._alias; + this._updateGsettings(); + } + + updateFirmware(fwVersion) { + this._fwVersion = fwVersion; + if (this._settingsItems) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + } + + updateSerial(serial) { + this._serialNo = serial; + if (this._settingsItems) { + this._settingsItems['serial'] = serial; + this._updateGsettings(); + } + } + + _setupAnrToggle() { + const anr = this._modelData.anr; + if (!anr) + return; + + this._config.toggle1Title = _('Noise Control'); + this._props.toggle1Visible = true; + + const labels = { + off: _('Off'), + low: _('Low'), + high: _('High'), + wind: _('Wind'), + }; + + const icons = { + off: 'bbm-anc-off-symbolic', + low: 'bbm-transperancy-symbolic', + high: 'bbm-anc-on-symbolic', + wind: 'bbm-anc-wind-symbolic', + }; + + this._toggle1Modes = Object.keys(anr); + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Name`] = ''; + this._config[`toggle1Button${i}Icon`] = null; + } + + this._toggle1Modes.forEach((mode, index) => { + const button = index + 1; + + this._config[`toggle1Button${button}Name`] = labels[mode] ?? mode; + this._config[`toggle1Button${button}Icon`] = icons[mode] ?? null; + }); + } + + _setupAudioModeToggle() { + this._log.info('Setup Audio mode toggle'); + + this._config.toggle1Title = _('Modes'); + this._props.toggle1Visible = true; + + this._toggle1AudioModes = []; + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Name`] = ''; + this._config[`toggle1Button${i}Icon`] = null; + } + + for (const mode of this._audioModes) { + if (!mode.ui) + continue; + + this._toggle1AudioModes.push(mode); + } + + this._toggle1AudioModes.forEach((mode, index) => { + const button = index + 1; + const iconMode = AudioModes[mode.id]; + + this._config[`toggle1Button${button}Name`] = mode.name; + this._config[`toggle1Button${button}Icon`] = `bbm-mode-${iconMode}-symbolic`; + }); + + const entries = this._toggle1AudioModes.map((mode, index) => [mode.index, index + 1]); + this._toggle1AudioModeMap = new Map(entries); + this._config.labelIndicatorEnabled = 1; + + + this.dataHandler?.setConfig(this._config); + + this.updateAudioModeCurrent(this._currentAudioMode, true); + } + + _addCNC() { + this._log.info('Add CNC UI'); + this._cncLevel = this._modelData.audioModes.nc.level / 2; + this._cncEnabled = false; + this._config.optionsBox1 = ['check-button']; + this._config.box1CheckButton = [_('Noise Control')]; + this._config.optionsBox2 = ['check-button', 'slider']; + this._config.box2CheckButton = [_('Noise Control')]; + this._config.box2SliderTitle = _('Noise Cancellation'); + this._props.optionsBoxVisible = 1; + this.dataHandler?.setConfig(this._config); + } + + _addSpatialMode() { + this._log.info('Add Spatial Audio'); + this._config.toggle2Title = _('Spatial Audio'); + this._props.toggle2Visible = true; + this._config.toggle2Button1Name = _('Off'); + this._config.toggle2Button1Icon = 'bbm-spatial-off-symbolic'; + this._config.toggle2Button2Name = _('Still'); + this._config.toggle2Button2Icon = 'bbm-spatial-still-symbolic'; + this._config.toggle2Button3Name = _('Motion'); + this._config.toggle2Button3Icon = 'bbm-spatial-motion-symbolic'; + this.dataHandler?.setConfig(this._config); + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'toggle2State') + this._toggle2ButtonClicked(value); + + if (command === 'box1CheckButton1State') + this._box1CheckButton1StateChanged(value); + + if (command === 'box2SliderValue') + this._box2SliderValueChanged(value); + + if (command === 'box2SliderIsDragging') + this._box2SliderIsDragging(value); + + if (command === 'box2CheckButton1State') + this._box2CheckButton1StateChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + if (!this._modelData) + return; + + if (!this._modelData?.batteryMultiple) + this._props.computedBatteryLevel = props.battery1Level; + else + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + + const isBatteryValid = + props.battery1Level > 0 && props.battery1Status !== 'disconnected' || + props.battery2Level > 0 && props.battery2Status !== 'disconnected'; + + if (!this._battInfoRecieved && isBatteryValid) + this._startConfiguration(props); + + this.dataHandler?.setProps(this._props); + } + + updateInEarState(bud1Status, bud2Status) { + this._bothBudsInEar = bud1Status && bud2Status; + this._budInEar = bud1Status || bud2Status; + + if (this._wearDetectionMode !== 0) { + let playbackMode = null; + + if (this._wearDetectionMode === 1) + playbackMode = this._bothBudsInEar ? 'play' : 'pause'; + else if (this._wearDetectionMode === 2) + playbackMode = this._budInEar ? 'play' : 'pause'; + + if (playbackMode) + this._mediaController?.changeActivePlayerState(playbackMode); + } + } + + updateAnr(mode) { + this._log.info(`updateAnr mode: ${hexBytes(mode)}`); + const anr = this._modelData.anr; + for (const [index, modeKey] of this._toggle1Modes.entries()) { + if (anr[modeKey] === mode) { + if (this._props.toggle1State !== index + 1) { + this._props.toggle1State = index + 1; + this.dataHandler?.setProps(this._props); + } + return; + } + } + } + + _toggle1ButtonClicked(index) { + if (this._modelData.audioModes) + this._toggle1ButtonClickedAudioMode(index); + else if (this._modelData.anr) + this._toggle1ButtonClickedAnr(index); + } + + _toggle1ButtonClickedAnr(index) { + const mode = this._toggle1Modes[index - 1]; + if (!mode) + return; + + const value = this._modelData.anr[mode]; + this._props.toggle1State = index; + this.dataHandler?.setProps(this._props); + this._boseBudsSocket?.setAnr(value); + } + + _toggle1ButtonClickedAudioMode(index) { + const mode = this._toggle1AudioModes[index - 1]; + + if (!mode) + return; + + this._props.toggle1State = index; + this.dataHandler?.setProps(this._props); + + this._currentAudioMode = mode.index; + this._settingsItems['current-mode'] = mode.index; + this._updateGsettings(); + + this._setCurrentAudioMode(mode.index); + } + + updateAudioMode(mode) { + this._log.info(`updateAudioMode id: ${mode.id}`); + + const index = this._audioModes.findIndex(m => m.id === mode.id); + + if (index === -1) { + this._audioModes.push({...mode}); + this._settingsItems['modes'] = this._audioModes; + this._updateGsettings(); + return; + } + + const current = this._audioModes[index]; + const areModesEqual = (a, b) => { + const areEqual = a.id === b.id && + a.editable === b.editable && + a.added === b.added && + a.fav === b.fav && + a.name === b.name && + a.flag === b.flag && + a.cnc === b.cnc && + a.autoCnc === b.autoCnc && + a.spatial === b.spatial && + a.wind === b.wind && + a.anc === b.anc; + return areEqual; + }; + + if (!areModesEqual(current, mode)) { + this._audioModes[index] = {...mode}; + this._settingsItems['modes'] = this._audioModes; + this._updateGsettings(); + } + } + + _setAudioMode(mode) { + this._boseBudsSocket?.setAudioMode(mode); + } + + updateAudioModeCurrent(index, uiUpdate = false) { + this._log.info(`updateAudioModeCurrent index: ${index}`); + + const mode = this._audioModes.find(m => m.index === index); + const text = mode?.name ?? ''; + + if (!uiUpdate && this._currentAudioMode !== index) { + this._currentAudioMode = index; + this._settingsItems['current-mode'] = index; + this._updateGsettings(); + } + + const toggleIndex = this._toggle1AudioModeMap?.get(index) ?? 0; + + let propsChanged = false; + + if (this._props.toggle1State !== toggleIndex) { + this._props.toggle1State = toggleIndex; + propsChanged = true; + } + + if (this._props.labelIndicator1 !== text) { + this._props.labelIndicator1 = text; + propsChanged = true; + } + + if (propsChanged) + this.dataHandler?.setProps(this._props); + } + + _setCurrentAudioMode(index) { + const mode = this._audioModes.find(m => m.index === index); + const toggleIndex = this._toggle1AudioModeMap?.get(index) ?? 0; + + let propsChanged = false; + + if (this._props.toggle1State !== toggleIndex) { + this._props.toggle1State = toggleIndex; + propsChanged = true; + } + + const text = mode?.name ?? ''; + if (this._props.labelIndicator1 !== text) { + this._props.labelIndicator1 = text; + propsChanged = true; + } + + if (propsChanged) + this.dataHandler?.setProps(this._props); + + this._boseBudsSocket?.setCurrentAudioMode(index); + } + + updateAudioModeFavorites(modeCount, favorites) { + this._log.info(`updateAudioModeFavorites favorites: ${JSON.stringify(favorites)}`); + this._modeCount = modeCount; + } + + _setAudioModeFavorites(favorites) { + this._boseBudsSocket?.setAudioModeFavorites(this._modeCount, favorites); + } + + updateAudioModeRestore(enabled) { + this._log.info(`updateAudioModeRestore enabled: ${enabled}`); + if (this._restoreAudioMode !== enabled) { + this._restoreAudioMode = enabled; + this._settingsItems['restore-mode'] = enabled; + this._updateGsettings(); + } + } + + _setRestoreAudioMode(enable) { + this._boseBudsSocket?.setRestoreAudioMode(enable); + } + + updateCnc(level, enabled) { + this._log.info(`updateCnc level: ${level} enabled: ${enabled}`); + const nc = this._modelData.audioModes?.nc; + if (!nc) + return; + + const maxLevel = nc.level; + const step = nc.steps; + level = Math.max(0, Math.min(maxLevel, level)); + level = Math.round(level / step) * step; + if (this._cncLevel === level && this._cncEnabled === enabled) + return; + + this._cncLevel = level; + this._cncEnabled = enabled; + this._props.box1CheckButton1State = enabled; + this._props.box2CheckButton1State = enabled; + this._props.optionsBoxVisible = enabled ? 2 : 1; + this._props.box2SliderValue = level * 100 / maxLevel; + this.dataHandler?.setProps(this._props); + } + + _box1CheckButton1StateChanged(value) { + const enabled = value === 1; + if (this._cncEnabled === enabled) + return; + + this._cncEnabled = enabled; + this._boseBudsSocket?.setCnc(this._cncLevel, this._cncEnabled); + this._props.box1CheckButton1State = value; + this._props.box2CheckButton1State = value; + this._props.optionsBoxVisible = enabled ? 2 : 1; + this.dataHandler?.setProps(this._props); + } + + _box2CheckButton1StateChanged(value) { + const enabled = value === 1; + if (this._cncEnabled === enabled) + return; + + this._cncEnabled = enabled; + this._boseBudsSocket?.setCnc(this._cncLevel, this._cncEnabled); + this._props.box1CheckButton1State = value; + this._props.box2CheckButton1State = value; + this._props.optionsBoxVisible = enabled ? 2 : 1; + this.dataHandler?.setProps(this._props); + } + + _box2SliderValueChanged(value) { + const nc = this._modelData.audioModes?.nc; + const maxLevel = nc.level; + const step = nc.steps; + + let level = Math.round(value * maxLevel / 100); + level = Math.max(0, Math.min(maxLevel, level)); + level = Math.round(level / step) * step; + + if (this._cncLevel === level) + return; + + this._cncLevel = level; + this._props.box2SliderValue = this._cncLevel * 100 / maxLevel; + this._boseBudsSocket?.setCnc(level, this._cncEnabled); + } + + _box2SliderIsDragging(value) { + if (value === 0) { + const maxLevel = this._modelData.audioModes.nc.level; + this._props.box2SliderValue = this._cncLevel * 100 / maxLevel; + this.dataHandler?.setProps(this._props); + } + } + + updateSpatialAudio(mode) { + if (mode < 0 || mode > 2) + return; + + this._props.toggle2State = mode + 1; + this.dataHandler?.setProps(this._props); + } + + _toggle2ButtonClicked(index) { + const mode = index - 1; + this._props.toggle2State = index; + this.dataHandler?.setProps(this._props); + this._boseBudsSocket?.setSpatialAudio(mode); + } + + updateEq(arr) { + let preset = 'custom'; + + for (const [name, values] of Object.entries(this._modelData.eq.presets ?? {})) { + if (isArrayEqual(arr, values)) { + preset = name; + break; + } + } + + let settingsChanged = false; + + if (this._settingsItems['eq-preset'] !== preset) { + this._settingsItems['eq-preset'] = preset; + settingsChanged = true; + } + + if (!isArrayEqual(arr, this._customEq)) { + this._customEq = arr; + this._settingsItems['eq-custom'] = arr; + settingsChanged = true; + } + + if (settingsChanged) + this._updateGsettings(); + } + + _setCustomEq(oldGains, newGains) { + for (let i = 0; i < oldGains.length; i++) { + if (oldGains[i] !== newGains[i]) + this._boseBudsSocket?.setEq(newGains[i], i); + } + } + + updateSideTone(level) { + this._log.info(`updateSideTone level: ${level}`); + if (this._sideTone !== level) { + this._sideTone = level; + this._settingsItems['side-tone'] = level; + this._updateGsettings(); + } + } + + _setSideTone(level) { + this._boseBudsSocket?.setSideTone(level); + } + + updateDualConnection(enabled) { + this._log.info(`updateDualConnection enabled: ${enabled}`); + if (this._multipoint !== enabled) { + this._multipoint = enabled; + this._settingsItems['multipoint'] = enabled; + this._updateGsettings(); + } + } + + _setMultipoint(enabled) { + this._boseBudsSocket?.setDualConnection(enabled); + } + + updateInEarSettings(enabled, autoPause, autoAnswer, autoTransparency) { + this._log.info(`updateInEarSettings enabled: ${enabled}, ` + + `autoPause: ${autoPause}, autoAnswer: ${autoAnswer}, ` + + `autoTransparency: ${autoTransparency}`); + + let update = false; + + if (this._inEarSettings !== enabled) { + this._inEarSettings = enabled; + this._settingsItems['in-ear'] = enabled; + update = true; + } + + if (this._modelData.autoPause && this._autoPause !== autoPause) { + this._autoPause = autoPause; + this._settingsItems['auto-pause'] = autoPause; + update = true; + } + + if (this._modelData.autoAnswer && this._autoAnswer !== autoAnswer) { + this._autoAnswer = autoAnswer; + this._settingsItems['auto-answer'] = autoAnswer; + update = true; + } + + if (this._modelData.autoTransparency && this._autoTransparency !== autoTransparency) { + this._autoTransparency = autoTransparency; + this._settingsItems['auto-transp'] = autoTransparency; + update = true; + } + + if (update) + this._updateGsettings(); + } + + _setInEarSettings(enabled) { + const autoPause = this._autoPause ?? false; + const autoAnswer = this._autoAnswer ?? false; + const autoTransparency = this._autoTransparency ?? false; + this._boseBudsSocket?.setInEarSettings(enabled, autoPause, autoAnswer, autoTransparency); + } + + updateAutoAnswer(enabled) { + this._log.info(`updateAutoAnswer enabled: ${enabled}`); + if (this._autoAnswer !== enabled) { + this._autoAnswer = enabled; + this._settingsItems['auto-answer'] = enabled; + this._updateGsettings(); + } + } + + _setAutoAnswer(enabled) { + if (this._modelData.inEarSettings) + this._setInEarSettings(this._inEarSettings); + else + this._boseBudsSocket?.setAutoAnswer(enabled); + } + + updateAutoPause(enabled) { + this._log.info(`updateAutoPause enabled: ${enabled}`); + if (this._autoPause !== enabled) { + this._autoPause = enabled; + this._settingsItems['auto-pause'] = enabled; + this._updateGsettings(); + } + } + + _setAutoPause(enabled) { + if (this._modelData.inEarSettings) + this._setInEarSettings(this._inEarSettings); + else + this._boseBudsSocket?.setAutoPause(enabled); + } + + updateAutoTransparency(enabled) { + this._log.info(`updateAutoTransparency enabled: ${enabled}`); + if (this._autoTransparency !== enabled) { + this._autoTransparency = enabled; + this._settingsItems['auto-transp'] = enabled; + this._updateGsettings(); + } + } + + _setAutoTransparency(enabled) { + if (this._modelData.inEarSettings) + this._setInEarSettings(this._inEarSettings); + else + this._boseBudsSocket?.setAutoTransparency(enabled); + } + + updateAutoPowerOffTimer(minutes) { + this._log.info(`updateAutoPowerOffTimer minutes: ${minutes}`); + if (this._autoPowerOffTimer !== minutes) { + this._autoPowerOffTimer = minutes; + this._settingsItems['auto-power'] = minutes; + this._updateGsettings(); + } + } + + _setAutoPowerOffTimer(minutes) { + this._boseBudsSocket?.setAutoPowerOffTimer(minutes); + } + + updateVoicePrompt(enabled, language, supported, batSupported, batEnabled) { + let update = false; + + if (this._voiceEnabled !== enabled) { + this._voiceEnabled = enabled; + this._settingsItems['voice-enabled'] = enabled; + update = true; + } + + if (this._voicePrompt !== language) { + this._voicePrompt = language; + this._settingsItems['voice-prompt'] = language; + update = true; + } + + if (this._supportedVoice !== supported) { + this._supportedVoice = supported; + this._settingsItems['supported-voice'] = supported; + update = true; + } + + if (this._voiceBatSupported !== batSupported) { + this._voiceBatSupported = batSupported; + this._settingsItems['vobat-sup'] = batSupported; + update = true; + } + + if (this._voiceBatEnabled !== batEnabled) { + this._voiceBatEnabled = batEnabled; + this._settingsItems['vobat-en'] = batEnabled; + update = true; + } + + if (update) + this._updateGsettings(); + } + + _setVoicePrompt() { + if (this._voicePrompt === 0xFF) + return; + + this._boseBudsSocket?.setVoicePrompt(this._voiceEnabled, this._voicePrompt, + this._voiceBatSupported, this._voiceBatEnabled); + } + + updateActionButton(buttonId, eventType, action) { + this._log.info(`updateActionButton buttonId: ${hexBytes(buttonId)}, ` + + `eventType: ${hexBytes(eventType)}, action: ${hexBytes(action)}`); + + const index = this._gestures.findIndex(g => g.id === buttonId && g.gesture === eventType); + + if (index === -1) + return; + + if (this._gestures[index].action === action) + return; + + this._gestures[index] = {...this._gestures[index], action}; + this._settingsItems['gestures'] = this._gestures; + this._updateGsettings(); + } + + _setGesture(gesture) { + this._boseBudsSocket?.setActionButton(gesture.id, gesture.gesture, gesture.action); + } + + updateBTDeviceList(devices) { + this._log.info('updateBTDeviceList'); + const deviceSet = new Set(devices); + this._deviceInfo = this._deviceInfo.filter(info => deviceSet.has(info.id)); + + for (const id of devices) { + if (!this._deviceInfo.some(info => info.id === id)) { + this._deviceInfo.push({ + id, + name: '', + connected: false, + state: BtDeviceState.NotInitialized, + }); + } + } + + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + updateBTDeviceInfo(mac, name, connected) { + this._log.info(`updateBTDeviceInfo connected: ${connected}`); + + const device = this._deviceInfo.find(info => info.id === mac); + if (!device) + return; + + let changed = false; + + if (device.name !== name) { + device.name = name; + changed = true; + } + + if (device.connected !== connected) { + device.connected = connected; + changed = true; + } + + if (device.state !== BtDeviceState.Ready) { + device.state = BtDeviceState.Ready; + changed = true; + } + + if (changed) { + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + } + + updatePairingMode(enabled) { + this._log.info(`updatePairingMode enabled: ${enabled}`); + if (this._pairingMode !== enabled) { + this._pairingMode = enabled; + this._settingsItems['pairing-mode'] = enabled; + this._updateGsettings(); + } + } + + _setPairingMode(enabled) { + this._boseBudsSocket?.setPairingMode(enabled); + } + + _startBTTimeout(id, operation) { + const timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { + const pending = this._pendingBTOperations.get(id); + + if (!pending) + return GLib.SOURCE_REMOVE; + + const device = this._deviceInfo.find(info => info.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._pendingBTOperations.delete(id); + + return GLib.SOURCE_REMOVE; + }); + + this._pendingBTOperations.set(id, {operation, timeoutId}); + } + + + _stopBTTimeout(id) { + const pending = this._pendingBTOperations.get(id); + if (!pending) + return; + + if (pending.timeoutId) + GLib.Source.remove(pending.timeoutId); + + this._pendingBTOperations.delete(id); + } + + updateConnectError() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'connect'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + updateConnectStatus(id, connected) { + const device = this._deviceInfo.find(info => info.id === id); + + if (!device) + return; + + this._stopBTTimeout(id); + device.connected = connected; + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + _connectBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'connect'); + this._boseBudsSocket?.connectBTDevice(id); + } + + updateDisconnectError() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'disconnect'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + updateDisconnectStatus(id) { + const device = this._deviceInfo.find(device => device.id === id); + + if (!device) + return; + + device.connected = false; + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._stopBTTimeout(id); + } + + _disconnectBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'disconnect'); + this._boseBudsSocket?.disconnectBTDevice(id); + } + + updateRemoveBTDeviceError() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'remove'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + updateRemoveBTDeviceStatus(id) { + this._deviceInfo = this._deviceInfo.filter(device => device.id !== id); + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._stopBTTimeout(id); + } + + _removeBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'remove'); + this._boseBudsSocket?.removeBTDevice(id); + } + + updateRoutingStatus(id) { + if (this._activeDevice !== id) { + this._activeDevice = id; + this._settingsItems['active-dev'] = id; + this._updateGsettings(); + } + } + + _setRoutingBTDevice(id) { + this._boseBudsSocket?.setRoutingBTDevice(id); + } + + updateOwnDeviceId(id) { + if (this._ownDevice !== id) { + this._ownDevice = id; + this._settingsItems['own-dev'] = id; + this._updateGsettings(); + } + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'boseBuds', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + this._boseBudsSocket?.destroy(); + this._boseBudsSocket = null; + + for (const {timeoutId} of this._pendingBTOperations.values()) + GLib.Source.remove(timeoutId); + + this._pendingBTOperations.clear(); + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + if (this._mediaHandlerId) + this._mediaController?.disconnect(this._mediaHandlerId); + this._mediaHandlerId = null; + this._mediaController?.destroy(); + this._mediaController = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + diff --git a/bbm/lib/devices/boseBuds/boseBudsSocket.js b/bbm/lib/devices/boseBuds/boseBudsSocket.js new file mode 100644 index 0000000000..32ff1f5c89 --- /dev/null +++ b/bbm/lib/devices/boseBuds/boseBudsSocket.js @@ -0,0 +1,1272 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import {bytesToHex, hexToBytes} from '../deviceUtils.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {Operator, CommandType, BudId} from './boseBudsConfig.js'; + +/** +Reference Material and Credits +https://github.com/Denton-L/based-connect/tree/master + +https://github.com/aaronsb/bosectl/blob/main/docs/architecture.md + +https://github.com/myNameArnav/libreqc/tree/main/docs +**/ + +const MAX_BUFFER_SIZE = 2048; + +export const BoseBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BoseSocket', +}, class BoseBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, modelData, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `BoseSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('BoseSocket init'); + this._callbacks = callbacks; + this._rxBuffer = []; + this._txQueue = []; + this._pendingRequest = null; + this._pendingTimeout = null; + this._modelData = modelData; + this._battInfo = { + battery1Level: 0, + battery2Level: 0, + battery3Level: 0, + battery1Status: 'discharging', + battery2Status: 'discharging', + battery3Status: 'discharging', + }; + + this.startSocket(); + } + + postConnectInitialization() { + if (this._modelData.legacy) + this._getInitPacket(); + + this._getConfiguration(); + } + + _queueRequest(command, operator, payload, loginfo = '') { + this._txQueue.push({command, operator, payload, loginfo}); + this._processQueue(); + } + + _processQueue() { + if (this._pendingRequest) + return; + + if (this._txQueue.length === 0) + return; + + const item = this._txQueue.shift(); + + if (item.loginfo) + this._log.info(item.loginfo); + + this._encodeBose(item.command, item.operator, item.payload); + this._pendingRequest = item; + + if (this._pendingTimeout) { + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + } + + this._pendingTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + this._log.info(`Response Timeout type: ${hexBytes(item.command)}`); + this._pendingRequest = null; + this._processQueue(); + this._pendingTimeout = null; + return GLib.SOURCE_REMOVE; + }); + } + + _completePendingRequest(msg) { + if (!this._pendingRequest) + return; + + if (msg.command !== this._pendingRequest.command) + return; + + if (this._pendingTimeout) + GLib.source_remove(this._pendingTimeout); + + this._pendingTimeout = null; + this._pendingRequest = null; + this._processQueue(); + } + + _encodeBose(command, operator, payload = []) { + const out = [ + command >> 8 & 0xFF, + command & 0xFF, + operator & 0x0F, + payload.length & 0xFF, + ...payload, + ]; + + this.sendMessage(out); + } + + processData(bytes) { + this._rxBuffer.push(...bytes); + + while (true) { + const msg = this._extractMessage(); + if (!msg) + break; + + this._handleMessage(msg); + this._completePendingRequest(msg); + } + } + + _extractMessage() { + const buf = this._rxBuffer; + + if (buf.length > MAX_BUFFER_SIZE) { + this._log.info('RX buffer overflow, clearing'); + buf.length = 0; + return null; + } + + if (buf.length < 4) + return null; + + const payloadLength = buf[3]; + const totalLength = 4 + payloadLength; + + if (buf.length < totalLength) + return null; + + return this._parseMessage(buf.splice(0, totalLength)); + } + + _parseMessage(raw) { + return { + command: raw[0] << 8 | raw[1], + operator: raw[2] & 0x0F, + payload: raw.slice(4), + }; + } + + _handleMessage(msg) { + this._log.info(`command: ${hexBytes(msg.command)} operator: ${hexBytes(msg.operator)} ` + + `payload: [${hexBytes(msg.payload)}]`); + + if (msg.payload.length < 1) + return; + + const isStatus = msg.operator === Operator.STATUS; + const isResult = msg.operator === Operator.RESULT; + const isProcessing = msg.operator === Operator.PROCESSING; + + switch (msg.command) { + case CommandType.NOTIFICATION_BY_BLOCK: { + if (isStatus) + this._parseNotificationByBlock(msg.payload); + break; + } + + case CommandType.FIRMWARE: { + if (isStatus) + this._parseFirmware(msg.payload); + break; + } + + case CommandType.SERIAL: { + if (isStatus) + this._parseSerial(msg.payload); + break; + } + + case CommandType.BATTERY: { + if (isStatus) + this._parseBatteryLevel(msg.payload); + break; + } + + case CommandType.CHARGING_STATE: { + if (isStatus) + this._parseBatteryStatus(msg.payload); + break; + } + + case CommandType.INEAR_STATE: { + if (isStatus && !this._modelData.batterySingle) + this._parseInEarState(msg.payload); + break; + } + + case CommandType.EQ: { + if (this._modelData.eq && isStatus) + this._parseEq(msg.payload); + break; + } + + case CommandType.ANR: { + if (this._modelData.anr) + this._parseAnr(msg.payload); + break; + } + + case CommandType.AUDIOMODE_CAPABILTY: { + if (this._modelData.audioModes && isStatus) + this._parseAudioModesCapability(msg.payload); + break; + } + + case CommandType.AUDIOMODE_CURRENT: { + if (this._modelData.audioModes && (isStatus || isResult)) + this._parseAudioModeCurrent(msg.payload); + break; + } + + case CommandType.AUDIOMODE_RESTORE: { + if (this._modelData.audioModes && isResult) + this._parseRestoreAudioMode(msg.payload); + break; + } + + case CommandType.AUDIOMODE_FAV: { + if (this._modelData.audioModes && isStatus) + this._parseAudioModeFavorites(msg.payload); + break; + } + + case CommandType.AUDIOMODE_CONFIG: { + if (this._modelData.audioModes && isStatus) + this._parseAudioMode(msg.payload); + break; + } + + case CommandType.CNC: { + if (this._modelData.audioModes?.showNCInterface && isStatus) + this._parseCnc(msg.payload); + break; + } + + case CommandType.SPATIAL_AUDIO: { + if (this._modelData.audioModes?.spatialMode && isStatus) + this._parseSpatialAudio(msg.payload); + break; + } + + case CommandType.DUALCONNECTION: { + if (this._modelData.dualConnection && isStatus) + this._parseDualConnection(msg.payload); + break; + } + + case CommandType.SIDETONE: { + if (this._modelData.sideTone && isStatus) + this._parseSideTone(msg.payload); + break; + } + + case CommandType.INEAR_SETTINGS: { + if (this._modelData.inEarSettings) + this._parseInEarSettings(msg.payload); + break; + } + + case CommandType.AUTO_ANSWER: { + if (!this._modelData.inEarSettings && this._modelData.autoAnswer && isStatus) + this._parseAutoAnswer(msg.payload); + break; + } + + case CommandType.AUTO_PAUSE: { + if (!this._modelData.inEarSettings && this._modelData.autoPause && isStatus) + this._parseAutoPause(msg.payload); + break; + } + + case CommandType.AUTO_TRANSP: { + if (!this._modelData.inEarSettings && this._modelData.autoTransparency && isStatus) + this._parseAutoTransparency(msg.payload); + break; + } + + case CommandType.VOICE_PROMPTS: { + if (this._modelData.voicePrompt && isStatus) + this._parseVoicePrompt(msg.payload); + break; + } + + case CommandType.AUTO_POWER_OFF_TIME: { + if (this._modelData.automaticPowerOffTimer && isStatus) + this._parseAutoPowerOffTimer(msg.payload); + break; + } + + case CommandType.BUTTONS: { + if (this._modelData.gestureOptions && isStatus) + this._parseActionButton(msg.payload); + break; + } + + case CommandType.GET_ALL_DEVICES: { + if (this._modelData.dualConnection && (isResult || isStatus)) + this._parseAllBTDevices(msg.payload); + break; + } + + case CommandType.DEVICE_INFO: { + if (this._modelData.dualConnection && (isResult || isStatus || isProcessing)) + this._parseBTDeviceInfo(msg.payload); + break; + } + + case CommandType.PAIRING_MODE: { + if (this._modelData.dualConnection) + this._parsePairingMode(msg.payload); + break; + } + + case CommandType.CONNECT_DEVICE: { + if (this._modelData.dualConnection) + this._parseConnectBTDevice(msg); + break; + } + + case CommandType.DISCONNECT_DEVICE: { + if (this._modelData.dualConnection) + this._parseDisconnectBTDevice(msg); + break; + } + + case CommandType.REMOVE_DEVICE: { + if (this._modelData.dualConnection) + this._parseRemoveBTDevice(msg); + break; + } + + case CommandType.DEVICE_ROUTING: { + if (this._modelData.dualConnection) + this._parseRoutingBTDevice(msg); + break; + } + + case CommandType.GET_OWN_DEVICE_ID: { + if (this._modelData.dualConnection && isStatus) + this._parseOwnDeviceId(msg.payload); + break; + } + + default: + this._log.info(`Unhandled command ${hexBytes(msg.command)}`); + } + } + + _encode(command, operator, loginfo, payload = []) { + this._queueRequest(command, operator, payload, loginfo); + } + + _getInitPacket() { + const loginfo = 'Get Init'; + this._encode(CommandType.INIT, Operator.GET, loginfo); + } + + _getConfiguration() { + this._setNotificationByBlock(); + this._getFirmware(); + this._getSerial(); + this._getBatteryLevel(); + this._getBatteryStatus(); + + if (!this._modelData.batterySingle) + this._getInEarState(); + + if (this._modelData.eq) + this._getEq(); + + if (this._modelData.anr) + this._getAnr(); + + if (this._modelData.audioModes) { + this._getAudioModesCapability(); + this._getAllAudioModes(); + this._getAudioModeCurrent(); + this._getRestoreAudioMode(); + this._getAudioModeFavorites(); + } + + if (this._modelData.audioModes?.showNCInterface) + this._getCnc(); + + if (this._modelData.audioModes?.spatialMode) + this._getSpatialAudio(); + + if (this._modelData.dualConnection) + this._getDualConnection(); + + if (this._modelData.sideTone) + this._getSideTone(); + + if (this._modelData.inEarSettings) + this._getInEarSettings(); + + if (!this._modelData.inEarSettings && this._modelData.autoAnswer) + this._getAutoAnswer(); + + if (!this._modelData.inEarSettings && this._modelData.autoPause) + this._getAutoPause(); + + if (!this._modelData.inEarSettings && this._modelData.autoTransparency) + this._getAutoTransparency(); + + if (this._modelData.voicePrompt) + this._getVoicePrompt(); + + if (this._modelData.automaticPowerOffTimer) + this._getAutoPowerOffTimer(); + + if (this._modelData.gestureOptions) + this._getActionButton(); + + if (this._modelData.dualConnection) { + this._getOwnDeviceId(); + this._getRoutingBTDevice(); + this._getPairingMode(); + this._getAllBTDevices(); + } + } + + _setNotificationByBlock() { + const loginfo = 'Set NotificationByBlock'; + const blocks = [0, 1, 5, 9]; + + if (this._modelData.dualConnection) + blocks.push(4); + + if (this._modelData.audioModes) + blocks.push(31); + + blocks.sort((a, b) => a - b); + const notification = new Uint8Array(4); + + for (const block of blocks) { + const byteIndex = 3 - Math.floor(block / 8); + const bitIndex = block % 8; + + notification[byteIndex] |= 1 << bitIndex; + } + + const payload = [0x01, ...notification]; + this._encode(CommandType.NOTIFICATION_BY_BLOCK, Operator.SETGET, loginfo, payload); + } + + _parseNotificationByBlock(payload) { + if (payload.length < 4) + return; + + this._log.info('Parse NotificationByBlock'); + const blocks = []; + + for (let block = 0; block < 32; block++) { + const byteIndex = 3 - Math.floor(block / 8); + const bitIndex = block % 8; + + if (payload[byteIndex] & 1 << bitIndex) + blocks.push(block); + } + + this._log.info(`Notification blocks: [${blocks.join(', ')}]`); + } + + _getFirmware() { + const loginfo = 'Get Firmware'; + this._encode(CommandType.FIRMWARE, Operator.GET, loginfo); + } + + _parseFirmware(payload) { + if (!payload.length) + return; + + const decoder = new TextDecoder(); + const fw = decoder.decode(Uint8Array.from(payload)); + + this._log.info(`Parse Firmware: ${fw}`); + this._callbacks?.updateFirmware?.(fw); + } + + _getSerial() { + const loginfo = 'Get Serial'; + this._encode(CommandType.SERIAL, Operator.GET, loginfo); + } + + _parseSerial(payload) { + if (!payload.length) + return; + + const decoder = new TextDecoder(); + const serial = decoder.decode(Uint8Array.from(payload)); + this._callbacks?.updateSerial?.(serial); + } + + _getBatteryLevel() { + const loginfo = 'Get Battery Level'; + this._encode(CommandType.BATTERY, Operator.GET, loginfo); + } + + _parseBatteryLevel(payload) { + if (this._modelData.legacy) { + if (payload.length < 1) + return; + + this._battInfo.battery1Level = payload[0]; + this._callbacks?.updateBatteryProps?.(this._battInfo); + return; + } + + if (payload.length < 4) + return; + + this._log.info('Parse Battery Level'); + + for (let i = 0; i + 3 < payload.length; i += 4) { + const level = payload[i]; + const budId = payload[i + 3]; + + switch (budId) { + case BudId.Left: + this._battInfo.battery1Level = level; + break; + + case BudId.Right: + this._battInfo.battery2Level = level; + break; + + case BudId.Case: + this._battInfo.battery3Level = level; + break; + + case BudId.Single: + default: + this._battInfo.battery1Level = level; + break; + } + } + + this._callbacks?.updateBatteryProps?.(this._battInfo); + } + + _getBatteryStatus() { + const loginfo = 'Get Battery Status'; + this._encode(CommandType.CHARGING_STATE, Operator.GET, loginfo); + } + + _parseBatteryStatus(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Battery Status'); + + const status = payload[0] === 1 ? 'charging' : 'discharging'; + + if (this._modelData.batterySingle) + this._battInfo.battery1Status = status; + else + this._battInfo.battery3Status = status; + + this._callbacks?.updateBatteryProps?.(this._battInfo); + } + + _getInEarState() { + const loginfo = 'Get InEar State'; + this._encode(CommandType.INEAR_STATE, Operator.GET, loginfo); + } + + _parseInEarState(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse In Ear State'); + + const left = (payload[0] & 0x01) === 0x01; + const right = (payload[0] & 0x02) === 0x02; + + this._callbacks?.updateInEarState?.(left, right); + } + + _getEq() { + const loginfo = 'Get EqConfig'; + this._encode(CommandType.EQ, Operator.GET, loginfo); + } + + _parseEq(payload) { + const bands = this._modelData?.eq?.band ?? []; + const range = this._modelData?.eq?.range ?? 0; + const bandCount = bands.length; + + if (payload.length !== bandCount * 4) { + this._log.info(`Parse EqBand: unexpected payload length ${payload.length}`); + return; + } + + this._log.info('Parse EqBand'); + + const arr = []; + + for (let i = 0; i < bandCount; i++) { + const offset = i * 4; + + let minVal = payload[offset]; + if (minVal >= 128) + minVal -= 256; + + let maxVal = payload[offset + 1]; + if (maxVal >= 128) + maxVal -= 256; + + if (Math.abs(minVal) !== range || Math.abs(maxVal) !== range) + this._log.info(`EQ range mismatch ${minVal} - ${maxVal}, expected: ±${range}`); + + let current = payload[offset + 2]; + if (current >= 128) + current -= 256; + + const band = payload[offset + 3]; + + arr.push({current, band}); + } + + this._callbacks?.updateEq?.(arr); + } + + setEq(current, band) { + const loginfo = 'Set EqBand'; + const payload = [current & 0xFF, band]; + this._encode(CommandType.EQ, Operator.SETGET, loginfo, payload); + } + + _getAnr() { + const loginfo = 'Get ANR'; + this._encode(CommandType.ANR, Operator.GET, loginfo); + } + + _parseAnr(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Anr'); + const mode = payload[0]; + this._callbacks?.updateAnr?.(mode); + } + + setAnr(mode) { + const loginfo = 'Set ANR'; + const payload = [mode]; + this._encode(CommandType.ANR, Operator.SETGET, loginfo, payload); + } + + _getAudioModesCapability() { + const loginfo = 'Get AudioModes Capability'; + this._encode(CommandType.AUDIOMODE_CAPABILTY, Operator.GET, loginfo); + } + + _parseAudioModesCapability(payload) { + this._log.info('Parse AudioModes Capability'); + + if (payload.length < 6) { + this._log.warn(`AudioModes Capability payload too short: ${payload.length}`); + return; + } + + const boseModes = payload[0]; + const userModes = payload[1]; + const flags = payload[5]; + const minFavorites = payload.length >= 7 ? payload[6] : 0; + + this._log.info( + `AudioModes: boseModes: ${boseModes}, ` + + `userModes: ${userModes}, ` + + `cnc: ${!!(flags & 0x01)}, ` + + `autoCnc: ${!!(flags & 0x02)}, ` + + `spatial: ${!!(flags & 0x04)}, ` + + `wind: ${!!(flags & 0x08)}, ` + + `favorites: ${!!(flags & 0x10)}, ` + + `ancToggle: ${!!(flags & 0x20)}, ` + + `minFavorites: ${minFavorites}` + ); + } + + _getAudioModeCurrent() { + const loginfo = 'Get AudioMode Current'; + this._encode(CommandType.AUDIOMODE_CURRENT, Operator.GET, loginfo); + } + + _parseAudioModeCurrent(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AudioMode Current'); + const modeIndex = payload[0]; + this._callbacks?.updateAudioModeCurrent?.(modeIndex); + } + + setCurrentAudioMode(modeIndex, playVoicePrompt = false) { + const loginfo = 'Set AudioMode Current'; + const payload = [modeIndex, playVoicePrompt ? 0x01 : 0x00]; + this._encode(CommandType.AUDIOMODE_CURRENT, Operator.START, loginfo, payload); + } + + _getRestoreAudioMode() { + const loginfo = 'Get AudioMode Restore'; + this._encode(CommandType.AUDIOMODE_RESTORE, Operator.GET, loginfo); + } + + _parseRestoreAudioMode(payload) { + if (payload.length < 1) + return; + + const enabled = payload[0] === 0x01; + this._callbacks?.updateAudioModeRestore?.(enabled); + } + + setRestoreAudioMode(enabled) { + const loginfo = 'Set AudioMode Restore'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.AUDIOMODE_RESTORE, Operator.SETGET, loginfo, payload); + } + + _getAudioModeFavorites() { + const loginfo = 'Get AudioMode Favorites'; + this._encode(CommandType.AUDIOMODE_FAV, Operator.GET, loginfo); + } + + _parseAudioModeFavorites(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AudioMode Favorites'); + + const modeCount = payload[0]; + const favorites = []; + const maskBytes = Math.ceil(modeCount / 8); + + for (let byteIndex = maskBytes; byteIndex >= 1; byteIndex--) { + const value = payload[byteIndex]; + + for (let bit = 0; bit < 8; bit++) { + if ((value >> bit & 0x01) === 1) + favorites.push((maskBytes - byteIndex) * 8 + bit); + } + } + + this._callbacks?.updateAudioModeFavorites?.(modeCount, favorites); + } + + setAudioModeFavorites(modeCount, favorites) { + const loginfo = 'Set AudioMode Favorites'; + const maskBytes = Math.ceil(modeCount / 8); + const payload = new Array(maskBytes + 1).fill(0); + + payload[0] = modeCount; + + for (const mode of favorites) { + const byteIndex = maskBytes - Math.floor(mode / 8); + payload[byteIndex] |= 1 << mode % 8; + } + + this._encode(CommandType.AUDIOMODE_FAV, Operator.SETGET, loginfo, payload); + } + + _getAllAudioModes() { + const loginfo = 'Get All AudioModes'; + this._encode(CommandType.AUDIOMODE_GETALL, Operator.START, loginfo); + } + + _parseAudioMode(payload) { + if (payload.length < 44) + return; + + this._log.info('Parse AudioMode Config'); + + const index = payload[0]; + const id = payload[2]; + const editable = payload[3] === 1; + const added = payload[4] === 1; + const fav = payload[5] === 1; + + let end = 6; + while (end < 38 && payload[end] !== 0) + end++; + + const name = new TextDecoder().decode(Uint8Array.from(payload.slice(6, end))); + + const config = { + index, + id, + editable, + added, + fav, + name, + flag: payload[41], + cnc: this._modelData.audioModes.nc.level - payload[42], + autoCnc: payload[43] === 1, + spatial: payload.length >= 45 ? payload[44] : 0, + wind: payload.length >= 47 ? payload[46] : 0, + anc: payload.length >= 48 ? payload[47] : 0, + }; + + this._log.info(`Parse AudioMode Config: ${JSON.stringify(config)}`); + this._callbacks?.updateAudioMode?.(config); + } + + setAudioMode(mode) { + const nameBuf = new Uint8Array(32); + nameBuf.set(new TextEncoder().encode(mode.name).slice(0, 31)); + + const payload = [ + mode.index, + 0x00, + mode.id, + ...nameBuf, + this._modelData.audioModes.nc.level - mode.cnc, + mode.autoCnc, + mode.spatial, + mode.wind, + mode.anc, + ]; + + const loginfo = 'Set AudioMode'; + this._encode(CommandType.AUDIOMODE_CONFIG, Operator.SETGET, loginfo, payload); + } + + _getCnc() { + const loginfo = 'Get Noise Cancellation'; + this._encode(CommandType.CNC, Operator.GET, loginfo); + } + + _parseCnc(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse Noise Cancellation'); + const level = this._modelData.audioModes.nc.level - payload[1]; + const enabled = (payload[2] & 0x01) !== 0; + + this._callbacks?.updateCnc?.(level, enabled); + } + + setCnc(level, enabled) { + const loginfo = 'Set Noise Cancellation'; + const payload = [this._modelData.audioModes.nc.level - level, enabled ? 1 : 0]; + this._encode(CommandType.CNC, Operator.SETGET, loginfo, payload); + } + + _getSpatialAudio() { + this._encode(CommandType.SPATIAL_AUDIO, Operator.GET, 'Get Spatial Audio'); + } + + _parseSpatialAudio(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse SpatialAudio'); + const mode = payload[0]; + this._callbacks?.updateSpatialAudio?.(mode); + } + + setSpatialAudio(mode) { + const loginfo = 'Set Spatial Audio'; + const payload = [mode]; + this._encode(CommandType.SPATIAL_AUDIO, Operator.SETGET, loginfo, payload); + } + + _getDualConnection() { + const loginfo = 'Get DualConnection'; + this._encode(CommandType.DUALCONNECTION, Operator.GET, loginfo); + } + + _parseDualConnection(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse DualConnection'); + const enabled = (payload[0] & 0x01) !== 0; + this._callbacks?.updateDualConnection?.(enabled); + } + + setDualConnection(enabled) { + const loginfo = 'Set DualConnection'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.DUALCONNECTION, Operator.SETGET, loginfo, payload); + } + + + _getSideTone() { + const loginfo = 'Get SideTone'; + this._encode(CommandType.SIDETONE, Operator.GET, loginfo); + } + + _parseSideTone(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse SideTone'); + const level = payload[1]; + this._callbacks?.updateSideTone?.(level); + } + + setSideTone(level) { + const loginfo = 'Set SideTone'; + const payload = [0x01, level]; + this._encode(CommandType.SIDETONE, Operator.SETGET, loginfo, payload); + } + + _getInEarSettings() { + const loginfo = 'Get InEarSettings'; + this._encode(CommandType.INEAR_SETTINGS, Operator.GET, loginfo); + } + + _parseInEarSettings(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse InEarSettings'); + + const enabled = payload[0] === 1; + const flags = payload[1]; + + const autoPause = (flags & 0x01) !== 0; + const autoAnswer = (flags & 0x02) !== 0; + const autoTransparency = (flags & 0x04) !== 0; + + this._log.info(`Parse InEarSettings enabled: ${enabled} autoPause: ${autoPause} ` + + `autoAnswer: ${autoAnswer} autoTransparency: ${autoTransparency}`); + + this._callbacks?.updateInEarSettings?.(enabled, autoPause, autoAnswer, autoTransparency); + } + + setInEarSettings(enabled, autoPauseEnabled, autoAnswerEnabled, autoTransparencyEnabled) { + const loginfo = 'Set InEarSettings'; + const payload = [ + enabled ? 0x01 : 0x00, + (autoPauseEnabled ? 0x01 : 0x00) | + (autoAnswerEnabled ? 0x02 : 0x00) | + (autoTransparencyEnabled ? 0x04 : 0x00), + ]; + this._encode(CommandType.INEAR_SETTINGS, Operator.SETGET, loginfo, payload); + } + + _getAutoAnswer() { + const loginfo = 'Get AutoAnswer'; + this._encode(CommandType.AUTO_ANSWER, Operator.GET, loginfo); + } + + _parseAutoAnswer(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AutoAnswer'); + const enabled = (payload[0] & 0x01) !== 0; + this._callbacks?.updateAutoAnswer?.(enabled); + } + + setAutoAnswer(enabled) { + const loginfo = 'Set AutoAnswer'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.AUTO_ANSWER, Operator.SETGET, loginfo, payload); + } + + _getAutoPause() { + const loginfo = 'Get AutoPause'; + this._encode(CommandType.AUTO_PAUSE, Operator.GET, loginfo); + } + + _parseAutoPause(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AutoPause'); + const enabled = payload[0] === 0x01; + this._callbacks?.updateAutoPause?.(enabled); + } + + setAutoPause(enabled) { + const loginfo = 'Set AutoPause'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.AUTO_PAUSE, Operator.SETGET, loginfo, payload); + } + + _getAutoTransparency() { + const loginfo = 'Get AutoTransparency'; + this._encode(CommandType.AUTO_TRANSP, Operator.GET, loginfo); + } + + _parseAutoTransparency(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AutoTransparency'); + const enabled = payload[0] === 0x01; + this._callbacks?.updateAutoTransparency?.(enabled); + } + + setAutoTransparency(enabled) { + const loginfo = 'Set AutoTransparency'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.AUTO_TRANSP, Operator.SETGET, loginfo, payload); + } + + _getAutoPowerOffTimer() { + const loginfo = 'Get AutoPowerOffTimer'; + this._encode(CommandType.AUTO_POWER_OFF_TIME, Operator.GET, loginfo); + } + + _parseAutoPowerOffTimer(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AutoPowerOffTimer'); + const minutes = payload[0]; + this._callbacks?.updateAutoPowerOffTimer?.(minutes); + } + + setAutoPowerOffTimer(minutes) { + const loginfo = 'Set AutoPowerOffTimer'; + const payload = [minutes]; + this._encode(CommandType.AUTO_POWER_OFF_TIME, Operator.SETGET, loginfo, payload); + } + + _getVoicePrompt() { + const loginfo = 'Get VoicePrompt'; + this._encode(CommandType.VOICE_PROMPTS, Operator.GET, loginfo); + } + + _parseVoicePrompt(payload) { + if (payload.length < 5) + return; + + this._log.info('Parse VoicePrompt'); + const value = payload[0]; + const enabled = Boolean(value >> 5 & 0x01); + const language = value & 0x1F; + const supported = payload[1] << 24 | payload[2] << 16 | payload[3] << 8 | payload[4]; + + let batSupported = false; + let batEnabled = false; + + if (payload.length >= 7) { + batSupported = payload[5] === 0x01; + batEnabled = payload[6] === 0x01; + } + + this._callbacks?.updateVoicePrompt?.(enabled, language, supported >>> 0, + batSupported, batEnabled); + } + + setVoicePrompt(enabled, language, batSupported, batEnabled) { + const loginfo = 'Set VoicePrompt'; + const payload = [(enabled ? 1 : 0) << 5 | language & 0x1F]; + + if (batSupported) + payload.push(batEnabled ? 0x01 : 0x00); + + this._encode(CommandType.VOICE_PROMPTS, Operator.SETGET, loginfo, payload); + } + + _getActionButton() { + const loginfo = 'Get Action Button'; + this._encode(CommandType.BUTTONS, Operator.GET, loginfo); + } + + _parseActionButton(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse Action Button'); + + const buttonId = payload[0]; + const eventType = payload[1]; + const action = payload[2]; + + this._callbacks?.updateActionButton?.(buttonId, eventType, action); + } + + setActionButton(id, eventType, action) { + const loginfo = 'Set Action Button'; + const payload = [id, eventType, action]; + this._encode(CommandType.BUTTONS, Operator.SETGET, loginfo, payload); + } + + _getAllBTDevices() { + const loginfo = 'Get AllBTDevices'; + this._encode(CommandType.GET_ALL_DEVICES, Operator.GET, loginfo); + } + + _parseAllBTDevices(payload) { + this._log.info('Parse AllBTDevices'); + + if (payload.length < 1) + return; + + if (payload.length === 1) { + this._callbacks?.updateBTDeviceList?.([]); + return; + } + + const devicesHex = []; + const devicesBytes = []; + + for (let i = 1; i + 5 < payload.length; i += 6) { + const mac = payload.slice(i, i + 6); + + devicesBytes.push(mac); + devicesHex.push(bytesToHex(mac)); + } + + this._callbacks?.updateBTDeviceList?.(devicesHex); + + for (const mac of devicesBytes) + this._getBTDeviceInfo(mac); + } + + _getBTDeviceInfo(macAddress) { + const loginfo = 'Get BTDevice Info'; + this._encode(CommandType.DEVICE_INFO, Operator.GET, loginfo, macAddress); + } + + _parseBTDeviceInfo(payload) { + if (payload.length < 9) + return; + + this._log.info('Parse BTDevice Info'); + const macAddress = bytesToHex(payload.slice(0, 6)); + const flags = payload[6]; + const connected = (flags & 0x01) !== 0; + const nameOffset = (flags & 0x04) !== 0 ? 10 : 9; + const deviceName = new TextDecoder().decode(new Uint8Array(payload.slice(nameOffset))); + + this._callbacks?.updateBTDeviceInfo?.(macAddress, deviceName, connected); + } + + _getPairingMode() { + const loginfo = 'Get PairingMode'; + this._encode(CommandType.PAIRING_MODE, Operator.GET, loginfo); + } + + _parsePairingMode(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse PairingMode'); + + const enabled = payload[0] === 0x01; + this._callbacks?.updatePairingMode?.(enabled); + } + + setPairingMode(enabled) { + const loginfo = 'Set PairingMode'; + const payload = [enabled ? 0x01 : 0x00]; + this._encode(CommandType.PAIRING_MODE, Operator.START, loginfo, payload); + } + + _parseConnectBTDevice(msg) { + const isStatus = msg.operator === Operator.STATUS; + const isError = msg.operator === Operator.ERROR; + + if (isError) { + this._callbacks?.updateConnectError(); + } else if (msg.payload.length > 6 && isStatus) { + this._log.info('Parse Connect Result'); + const macAddress = bytesToHex(msg.payload.slice(0, 6)); + const flags = msg.payload[6]; + const connected = (flags & 0x01) !== 0 || (flags & 0x02) !== 0; + this._callbacks?.updateConnectStatus?.(macAddress, connected); + } + } + + connectBTDevice(macHex) { + const loginfo = 'Connect BTDevice'; + const mac = hexToBytes(macHex); + this._encode(CommandType.CONNECT_DEVICE, Operator.START, loginfo, [0x00, ...mac]); + } + + _parseDisconnectBTDevice(msg) { + const isStatus = msg.operator === Operator.STATUS; + const isError = msg.operator === Operator.ERROR; + + if (isError) { + this._callbacks?.updateDisconnectError?.(); + } else if (msg.payload.length >= 6 && isStatus) { + this._log.info('Parse Disconnect Result'); + const macAddress = bytesToHex(msg.payload.slice(0, 6)); + this._callbacks?.updateDisconnectStatus?.(macAddress); + } + } + + disconnectBTDevice(macHex) { + const loginfo = 'Disconnect BTDevice'; + const mac = hexToBytes(macHex); + this._encode(CommandType.DISCONNECT_DEVICE, Operator.START, loginfo, mac); + } + + _parseRemoveBTDevice(msg) { + const isStatus = msg.operator === Operator.STATUS; + const isError = msg.operator === Operator.ERROR; + + if (isError) { + this._callbacks?.updateRemoveBTDeviceError?.(); + } else if (msg.payload.length >= 6 && isStatus) { + this._log.info('Parse Remove BTDevice Result'); + const macAddress = bytesToHex(msg.payload.slice(0, 6)); + this._callbacks?.updateRemoveBTDeviceStatus?.(macAddress); + } + } + + removeBTDevice(macHex) { + const loginfo = 'Remove BTDevice'; + const mac = hexToBytes(macHex); + this._encode(CommandType.REMOVE_DEVICE, Operator.START, loginfo, mac); + } + + _getRoutingBTDevice() { + const loginfo = 'Get Routing BTDevice'; + this._encode(CommandType.DEVICE_ROUTING, Operator.GET, loginfo); + } + + _parseRoutingBTDevice(msg) { + const isStatus = msg.operator === Operator.STATUS; + const isResult = msg.operator === Operator.RESULT; + + if (isResult && msg.payload.length >= 6 || isStatus && msg.payload.length >= 8) { + this._log.info('Parse Routing BTDevice'); + const routingAddress = isResult ? msg.payload.slice(0, 6) : msg.payload.slice(2, 8); + const mac = bytesToHex(routingAddress); + this._callbacks?.updateRoutingStatus?.(mac); + } + } + + setRoutingBTDevice(macHex) { + const loginfo = 'Set Routing BTDevice'; + const mac = hexToBytes(macHex); + this._encode(CommandType.DEVICE_ROUTING, Operator.START, loginfo, mac); + } + + _getOwnDeviceId() { + const loginfo = 'Get OwnDeviceId'; + this._encode(CommandType.GET_OWN_DEVICE_ID, Operator.GET, loginfo); + } + + _parseOwnDeviceId(payload) { + if (payload.length !== 6) + return; + + this._log.info('Parse OwnDeviceId'); + const macAddress = bytesToHex(payload); + this._callbacks?.updateOwnDeviceId?.(macAddress); + } + + destroy() { + super.destroy?.(); + } +}); diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QC35.js b/bbm/lib/devices/boseBuds/deviceConfigs/QC35.js new file mode 100644 index 0000000000..c0a74f749d --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QC35.js @@ -0,0 +1,25 @@ +'use strict'; + +export default { + name: 'QuietComfort 35', + id: '400C', + type: 'headband', + + batterySingle: true, + legacy: true, + + anr: { + off: 0x00, + low: 0x03, + high: 0x01, + }, + + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + automaticPowerOffTimer: [0, 5, 20, 40, 60, 180], + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QC35SE.js b/bbm/lib/devices/boseBuds/deviceConfigs/QC35SE.js new file mode 100644 index 0000000000..b940a71253 --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QC35SE.js @@ -0,0 +1,49 @@ +'use strict'; + +export default { + name: 'QuietComfort 35 II', + id: '4020', + type: 'headband', + + batterySingle: true, + legacy: true, + anr: { + off: 0x00, + low: 0x03, + high: 0x01, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + automaticPowerOffTimer: [0, 5, 20, 40, 60, 180], + voicePrompt: true, + + dualConnection: true, + maxConnected: 2, + gestureOptions: { + buttons: { + action: { + id: 0x10, + gestures: { + 'single': { + type: 'press', + byte: 0x04, + actions: { + 'anc': 0x02, + 'voice-assistant': 0x01, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QCEarbuds2.js b/bbm/lib/devices/boseBuds/deviceConfigs/QCEarbuds2.js new file mode 100644 index 0000000000..2600f99b65 --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QCEarbuds2.js @@ -0,0 +1,131 @@ +'use strict'; + +export default { + name: 'QuietComfort Earbuds II', + id: '4064', + type: 'earbuds', + + batteryMultiple: true, + batteryCase: true, + + eq: { + bands: ['bass', 'mid', 'treble'], + range: 8, + custom: true, + presets: { + flat: [0, 0, 0], + bassBoost: [8, 0, 0], + bassReducer: [-8, -2, 0], + trebleBoost: [0, 0, 6], + trebleReducer: [0, -2, -6], + }, + }, + + audioModes: { + defaultConfig: { + index: 256, + id: 0, + editable: true, + added: false, + ui: false, + fav: false, + name: '', + flag: 255, + cnc: 5, + autoCnc: false, + spatial: 0, + wind: false, + anc: false, + }, + presets: { + quiet: { + index: 0, + id: 1, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Quiet', + cnc: 10, + }, + + aware: { + index: 1, + id: 2, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Aware', + cnc: 0, + }, + }, + + ancToggle: false, + nc: {level: 10, steps: 1}, + autoNc: false, + windToggle: false, + showNCInterface: true, + spatialMode: false, + userMode: [ + 'commute', 'focus', 'home', 'music', 'outdoor', 'relax', + 'run', 'walk', 'work', 'workout', + ], + totalModes: 10, + maxAllowedFav: 10, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + // inEarSettings: true, + autoAnswer: true, + autoPause: true, + autoTransparency: true, + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + + gestureOptions: { + buttons: { + left: { + id: 0x04, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'voice-assistant': 0x01, + }, + }, + }, + }, + + right: { + id: 0x03, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'voice-assistant': 0x01, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Earbuds.js b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Earbuds.js new file mode 100644 index 0000000000..7ba2bf63e7 --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Earbuds.js @@ -0,0 +1,149 @@ +'use strict'; + +export default { + name: 'QuietComfort Ultra Earbuds (2nd Gen)', + id: '4062', + type: 'earbuds', + + batteryMultiple: true, + batteryCase: true, + + eq: { + bands: ['bass', 'mid', 'treble'], + range: 8, + custom: true, + presets: { + flat: [0, 0, 0], + bassBoost: [8, 0, 0], + bassReducer: [-8, -2, 0], + trebleBoost: [0, 0, 6], + trebleReducer: [0, -2, -6], + }, + }, + + audioModes: { + defaultConfig: { + index: 256, + id: 0, + editable: true, + added: false, + ui: false, + fav: false, + name: '', + flag: 255, + cnc: 5, + autoCnc: false, + spatial: 0, + wind: false, + anc: false, + }, + presets: { + quiet: { + index: 0, + id: 1, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Quiet', + cnc: 10, + }, + + aware: { + index: 1, + id: 2, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Aware', + cnc: 0, + }, + + immersion: { + index: 2, + id: 34, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Immersion', + cnc: 10, + spatial: 2, + }, + }, + + ancToggle: false, + nc: {level: 10, steps: 1}, + autoNc: true, + windToggle: false, + showNCInterface: true, + spatialMode: true, + userMode: [ + 'commute', 'focus', 'home', 'music', 'outdoor', 'relax', + 'run', 'walk', 'work', 'workout', + ], + totalModes: 10, + maxAllowedFav: 10, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + // inEarSettings: true, + autoAnswer: true, + autoPause: true, + autoTransparency: true, + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + + gestureOptions: { + buttons: { + left: { + id: 0x04, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'spatial': 0x13, + 'switch-device': 0x08, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + + right: { + id: 0x03, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'spatial': 0x13, + 'switch-device': 0x08, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Headphones.js b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Headphones.js new file mode 100644 index 0000000000..2ce460a60e --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Headphones.js @@ -0,0 +1,139 @@ +'use strict'; + +export default { + name: 'QuietComfort Ultra Headphones (2nd Gen)', + id: '4082', + type: 'headband', + + batterySingle: true, + + eq: { + bands: ['bass', 'mid', 'treble'], + range: 8, + custom: true, + presets: { + flat: [0, 0, 0], + bassBoost: [8, 0, 0], + bassReducer: [-8, -2, 0], + trebleBoost: [0, 0, 6], + trebleReducer: [0, -2, -6], + }, + }, + + audioModes: { + defaultConfig: { + index: 256, + id: 0, + editable: true, + added: false, + ui: false, + fav: false, + name: '', + flag: 255, + cnc: 5, + autoCnc: false, + spatial: 0, + wind: false, + anc: false, + }, + presets: { + quiet: { + index: 0, + id: 1, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Quiet', + cnc: 10, + }, + + aware: { + index: 1, + id: 2, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Aware', + cnc: 0, + }, + + immersion: { + index: 2, + id: 34, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Immersion', + cnc: 10, + spatial: 2, + }, + + cinema: { + index: 3, + id: 36, + editable: false, + added: true, + fav: true, + name: 'Cinema', + cnc: 5, + spatial: 1, + }, + }, + + ancToggle: true, + nc: {level: 10, steps: 1}, + autoNc: true, + windToggle: true, + showNCInterface: true, + spatialMode: true, + userMode: [ + 'commute', 'focus', 'home', 'music', 'outdoor', 'relax', + 'run', 'walk', 'work', 'workout', + ], + totalModes: 10, + maxAllowedFav: 10, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + // inEarSettings: true, + autoAnswer: true, + autoPause: true, + autoTransparency: true, + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + + gestureOptions: { + buttons: { + single: { + id: 0x80, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'battery-level': 0x03, + 'spatial': 0x13, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraEarbuds.js b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraEarbuds.js new file mode 100644 index 0000000000..ba7d8dd5bf --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraEarbuds.js @@ -0,0 +1,149 @@ +'use strict'; + +export default { + name: 'QuietComfort Ultra Earbuds', + id: '4072', + type: 'earbuds', + + batteryMultiple: true, + batteryCase: true, + + eq: { + bands: ['bass', 'mid', 'treble'], + range: 8, + custom: true, + presets: { + flat: [0, 0, 0], + bassBoost: [8, 0, 0], + bassReducer: [-8, -2, 0], + trebleBoost: [0, 0, 6], + trebleReducer: [0, -2, -6], + }, + }, + + audioModes: { + defaultConfig: { + index: 256, + id: 0, + editable: true, + added: false, + ui: false, + fav: false, + name: '', + flag: 255, + cnc: 5, + autoCnc: false, + spatial: 0, + wind: false, + anc: false, + }, + presets: { + quiet: { + index: 0, + id: 1, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Quiet', + cnc: 10, + }, + + aware: { + index: 1, + id: 2, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Aware', + cnc: 0, + }, + + immersion: { + index: 2, + id: 34, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Immersion', + cnc: 10, + spatial: 2, + }, + }, + + ancToggle: false, + nc: {level: 10, steps: 1}, + autoNc: false, + windToggle: false, + showNCInterface: true, + spatialMode: true, + userMode: [ + 'commute', 'focus', 'home', 'music', 'outdoor', 'relax', + 'run', 'walk', 'work', 'workout', + ], + totalModes: 10, + maxAllowedFav: 10, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + // inEarSettings: true, + autoAnswer: true, + autoPause: true, + autoTransparency: true, + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + + gestureOptions: { + buttons: { + left: { + id: 0x04, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'spatial': 0x13, + 'switch-device': 0x08, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + + right: { + id: 0x03, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'mode': 0x11, + 'spatial': 0x13, + 'switch-device': 0x08, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraHeadphones.js b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraHeadphones.js new file mode 100644 index 0000000000..166767743c --- /dev/null +++ b/bbm/lib/devices/boseBuds/deviceConfigs/QCUltraHeadphones.js @@ -0,0 +1,128 @@ +'use strict'; + +export default { + name: 'QuietComfort Ultra Headphones', + id: '4066', + type: 'headband', + + batterySingle: true, + + eq: { + bands: ['bass', 'mid', 'treble'], + range: 8, + custom: true, + presets: { + flat: [0, 0, 0], + bassBoost: [8, 0, 0], + bassReducer: [-8, -2, 0], + trebleBoost: [0, 0, 6], + trebleReducer: [0, -2, -6], + }, + }, + + audioModes: { + defaultConfig: { + index: 256, + id: 0, + editable: true, + added: false, + ui: false, + fav: false, + name: '', + flag: 255, + cnc: 5, + autoCnc: false, + spatial: 0, + wind: false, + anc: false, + }, + presets: { + quiet: { + index: 0, + id: 1, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Quiet', + cnc: 10, + }, + + aware: { + index: 1, + id: 2, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Aware', + cnc: 0, + }, + + immersion: { + index: 2, + id: 34, + editable: false, + added: true, + ui: true, + fav: true, + name: 'Immersion', + cnc: 10, + spatial: 2, + }, + }, + + ancToggle: false, + nc: {level: 10, steps: 1}, + autoNc: false, + windToggle: true, + showNCInterface: true, + spatialMode: true, + userMode: [ + 'commute', 'focus', 'home', 'music', 'outdoor', 'relax', + 'run', 'walk', 'work', 'workout', + ], + totalModes: 10, + maxAllowedFav: 10, + }, + + sideTone: { + off: 0x00, + low: 0x03, + mid: 0x02, + high: 0x01, + }, + + // inEarSettings: true, + autoAnswer: true, + autoPause: true, + autoTransparency: true, + dualConnection: true, + maxConnected: 2, + voicePrompt: true, + + gestureOptions: { + buttons: { + single: { + id: 0x80, + gestures: { + 'action-hold': { + type: 'tap', + byte: 0x09, + actions: { + 'disabled': 0x0E, + 'battery-level': 0x03, + 'spatial': 0x13, + 'voice-assistant': 0x01, + 'spotify-go': 0x10, + }, + }, + }, + }, + }, + }, + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/commonEmuns.js b/bbm/lib/devices/commonEmuns.js new file mode 100644 index 0000000000..a7710ed64e --- /dev/null +++ b/bbm/lib/devices/commonEmuns.js @@ -0,0 +1,14 @@ +'use strict'; + +export const BtDeviceState = { + NotInitialized: 0, + Ready: 1, + Processing: 2, +}; + +export const DeviceManagementAction = { + Connect: 0, + Disconnect: 1, + Remove: 2, + Routing: 3, +}; diff --git a/bbm/lib/devices/deviceUtils.js b/bbm/lib/devices/deviceUtils.js new file mode 100644 index 0000000000..0e34ee87fe --- /dev/null +++ b/bbm/lib/devices/deviceUtils.js @@ -0,0 +1,135 @@ +'use strict'; +import Gio from 'gi://Gio'; + +export const SppUUidType = 'serial'; +export const SppUUid = '00001101-0000-1000-8000-00805f9b34fb'; + +export function booleanFromByte(val) { + switch (val) { + case 0x00: + return false; + case 0x01: + return true; + default: + return null; + } +} + +export function isValidByte(val, enumObj) { + return Object.values(enumObj).includes(val); +} + +export function isArrayEqual(a, b) { + if (a === b) + return true; + + if (!a || !b) + return false; + + if (a.length !== b.length) + return false; + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return false; + } + + return true; +} + +export function bytesToHex(bytes) { + if (!bytes || bytes.length === 0) + return ''; + + return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(''); +} + +export function hexToBytes(hex) { + if (!hex || hex.length === 0 || hex.length % 2 !== 0) + return []; + + const bytes = []; + + for (let i = 0; i < hex.length; i += 2) + bytes.push(parseInt(hex.slice(i, i + 2), 16)); + + return bytes; +} + +export function buds2to1BatteryLevel(battProps) { + const bat1 = battProps.battery1Level; + const bat2 = battProps.battery2Level; + const status1 = battProps.battery1Status; + const status2 = battProps.battery2Status; + const isInvalid = level => level === null || level === undefined || level === 0; + if (status1 === 'charging' && !isInvalid(bat1) && isInvalid(bat2)) + return bat1; + + if (status2 === 'charging' && !isInvalid(bat2) && isInvalid(bat1)) + return bat2; + + if (status1 === 'charging' && status2 !== 'charging') + return isInvalid(bat2) ? 0 : bat2; + + if (status2 === 'charging' && status1 !== 'charging') + return isInvalid(bat1) ? 0 : bat1; + + if (isInvalid(bat1) && isInvalid(bat2)) + return 0; + + if (isInvalid(bat1)) + return bat2; + + if (isInvalid(bat2)) + return bat1; + + return bat1 < bat2 ? bat1 : bat2; +} + +export function validateProperties(settings, settingsKey, devicesList, defaults, devicePath) { + const device = devicesList.find(d => d.path === devicePath); + if (!device) + return; + + let changed = false; + + for (const key of Object.keys(device)) { + if (!(key in defaults)) { + delete device[key]; + changed = true; + } + } + + for (const [key, value] of Object.entries(defaults)) { + if (!(key in device)) { + device[key] = value; + changed = true; + } + } + + if (changed) { + settings.set_strv( + settingsKey, + devicesList.map(d => JSON.stringify(d)) + ); + } +} + + +export function launchConfigureWindow(path, type, dir, cancellable) { + const argv = ['gjs', '-m', `${dir}/script/moreSettings.js`, '--path', path, '--type', type]; + + try { + const proc = new Gio.Subprocess({argv, flags: Gio.SubprocessFlags.NONE}); + proc.init(cancellable); + /** + * Launches the configuration window GJS script as a separate subprocess. + * + * The extension does not keep a reference to the subprocess and does not attempt + * to terminate it on destroy(). The configuration script manages its own + * lifecycle and exits when the extension is disabled. + */ + } catch (e) { + console.log(`Failed to launch configure window for path: ${path} Err: ${e}`); + } +} diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds.js new file mode 100644 index 0000000000..bf9438846b --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds.js @@ -0,0 +1,35 @@ +'use strict'; + +// 1) Galaxy Buds +export default { + modelId: 1, + name: 'Galaxy Buds', + + features: { + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 4, + ambientVoiceFocus: true, + batteryType: true, + buildInfo: true, + current: true, + pairingMode: true, + seamlessConnection: true, + sppLegacyMessageHeader: true, + voltage: true, + }, + touchOptions: { + voiceAssistant: 0, + quickAmbientSound: 1, + volume: 2, + ambientSound: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2.js new file mode 100644 index 0000000000..fa044be38b --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2.js @@ -0,0 +1,52 @@ +'use strict'; + +// 5) Galaxy Buds 2 +export default { + modelId: 5, + name: 'Galaxy Buds2', + + features: { + advancedTouchLock: true, + advancedTouchLockForCalls: true, + ambientCustomize: true, + ambientCustomizeVolume: 2, + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientVolumeMax: 2, + autoAdjustSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + doubleTapVolume: true, + extraClearCallSound: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + headTracking: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + pairingMode: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2Pro.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2Pro.js new file mode 100644 index 0000000000..fa5204089b --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2Pro.js @@ -0,0 +1,52 @@ +'use strict'; + +// 6) Galaxy Buds 2 Pro +export default { + modelId: 6, + name: 'Galaxy Buds2 Pro', + + features: { + advancedTouchLock: true, + advancedTouchLockForCalls: true, + ambientCustomize: true, + ambientCustomizeVolume: 2, + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientVolumeMax: 2, + autoAdjustSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + detectConversations: true, + doubleTapVolume: true, + extraClearCallSound: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + headTracking: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3.js new file mode 100644 index 0000000000..7a55272b00 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3.js @@ -0,0 +1,43 @@ +'use strict'; + +// 8) Galaxy Buds 3 +export default { + modelId: 8, + name: 'Galaxy Buds3', + + features: { + advancedTouchLockForCalls: true, + advancedTouchIsPinch: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + doubleTapVolume: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + pairingMode: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + usesStem: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Fe.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Fe.js new file mode 100644 index 0000000000..7bc9f746a0 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Fe.js @@ -0,0 +1,57 @@ +'use strict'; + +// 10) Galaxy Buds 3 FE +export default { + modelId: 10, + name: 'Galaxy Buds3 FE', + + features: { + adaptiveNoiseControl: true, + advancedTouchLockForCalls: true, + advancedTouchIsPinch: true, + // ambientCustomize: true, + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 4, + autoAdjustSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + detectConversations: true, + doubleTapVolume: true, + extraClearCallSound: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + headTracking: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + noiseReductionAdjustments: true, + noiseReductionLevels: 4, + noiseTouchAndHoldNewVersion: true, + quickLaunchAdvance: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Pro.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Pro.js new file mode 100644 index 0000000000..56b72a143b --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Pro.js @@ -0,0 +1,59 @@ +'use strict'; + +// 9) Galaxy Buds 3 Pro +export default { + modelId: 9, + name: 'Galaxy Buds3 Pro', + + features: { + adaptiveNoiseControl: true, + advancedTouchLockForCalls: true, + advancedTouchIsPinch: true, + ambientCustomize: true, + ambientCustomizeVolume: 4, + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 4, + autoAdjustSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + detectConversations: true, + doubleTapVolume: true, + extraClearCallSound: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + headTracking: true, + lightingControl: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + noiseReductionAdjustments: true, + noiseReductionLevels: 4, + noiseTouchAndHoldNewVersion: true, + quickLaunchAdvance: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4.js new file mode 100644 index 0000000000..e04e06ec4d --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4.js @@ -0,0 +1,47 @@ +'use strict'; + +// 12) Galaxy Buds 4 +export default { + modelId: 12, + name: 'Galaxy Buds4', + + features: { + adaptiveNoiseControl: true, + advancedTouchLockForCalls: true, + advancedTouchIsPinch: true, + ambientSidetone: true, + ambientSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + doubleTapVolume: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + pairingMode: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + usesStem: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4Pro.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4Pro.js new file mode 100644 index 0000000000..1d997f6695 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4Pro.js @@ -0,0 +1,59 @@ +'use strict'; + +// 13) Galaxy Buds 4 Pro +export default { + modelId: 13, + name: 'Galaxy Buds4 Pro', + + features: { + adaptiveNoiseControl: true, + advancedTouchLockForCalls: true, + advancedTouchIsPinch: true, + ambientCustomize: true, + ambientCustomizeVolume: 4, + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 4, + autoAdjustSound: true, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + detectConversations: true, + doubleTapVolume: true, + extraClearCallSound: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + headTracking: true, + loudnessOptimization: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + noiseReductionAdjustments: true, + noiseReductionLevels: 4, + noiseTouchAndHoldNewVersion: true, + quickLaunchAdvance: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsCore.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsCore.js new file mode 100644 index 0000000000..8d8caff977 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsCore.js @@ -0,0 +1,46 @@ +'use strict'; + +// 11) Galaxy Buds Core +export default { + modelId: 11, + name: 'Galaxy Buds Core', + + features: { + advancedTouchLock: true, + advancedTouchLockForCalls: true, + ambientCustomize: true, + ambientCustomizeVolume: 2, + ambientSidetone: true, + ambientSound: true, + ambientVolumeMax: 2, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + doubleTapVolume: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + rename: true, + seamlessConnection: true, + smartThingsFind: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsFe.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsFe.js new file mode 100644 index 0000000000..05af1f296c --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsFe.js @@ -0,0 +1,46 @@ +'use strict'; + +// 7) Galaxy Buds FE +export default { + modelId: 7, + name: 'Galaxy Buds FE', + + features: { + advancedTouchLock: true, + advancedTouchLockForCalls: true, + ambientCustomize: true, + ambientCustomizeVolume: 2, + ambientSidetone: true, + ambientSound: true, + ambientVolumeMax: 2, + bixbyWakeup: true, + callPathControl: true, + caseBattery: true, + chargingState: true, + fmgRingWhileWearing: true, + gamingMode: true, + gearFitTest: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + noiseTouchAndHoldNewVersion: true, // Is new? Delete this line + rename: true, + seamlessConnection: true, + smartThingsFind: true, + stereoPan: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsLive.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsLive.js new file mode 100644 index 0000000000..f04a936c12 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsLive.js @@ -0,0 +1,39 @@ +'use strict'; + +// 3) Galaxy Buds Live +export default { + modelId: 3, + name: 'Galaxy Buds Live', + + features: { + ambientPassthrough: true, + bixbyWakeup: true, + buildInfo: true, + callPathControl: true, + caseBattery: true, + gamingMode: true, + hiddenAtMode: true, + noiseCancellation: true, + pairingMode: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + voltage: true, + }, + touchOptions: { + voiceAssistant: 1, + anc: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + iconResourceKey: 'Bean', + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds-bean', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPlus.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPlus.js new file mode 100644 index 0000000000..025df695d7 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPlus.js @@ -0,0 +1,38 @@ +'use strict'; + +// 2) Galaxy Buds+ +export default { + modelId: 2, + name: 'Galaxy Buds+', + + features: { + ambientExtraLoud: true, + ambientSidetone: true, + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 2, // 3 if ExtraLoud is set + buildInfo: true, + callPathControl: true, + caseBattery: true, + doubleTapVolume: true, + gamingMode: true, + hiddenAtMode: true, + pairingMode: true, + seamlessConnection: true, + smartThingsFind: true, + voltage: true, + }, + touchOptions: { + voiceAssistant: 1, + ambientSound: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPro.js b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPro.js new file mode 100644 index 0000000000..44db7d76e4 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPro.js @@ -0,0 +1,49 @@ +'use strict'; + +// 4) Galaxy Buds Pro +export default { + modelId: 4, + name: 'Galaxy Buds Pro', + + features: { + ambientCustomize: true, + ambientCustomizeVolume: 4, + ambientSidetone: true, + ambientSound: true, + ambientSoundVolume: true, + ambientVolumeMax: 3, // from GBC + bixbyWakeup: true, + buildInfo: true, + callPathControl: true, + caseBattery: true, + detectConversations: true, + doubleTapVolume: true, + gamingMode: true, + hiddenAtMode: true, + noiseCancellation: true, + noiseControl: true, + noiseControlModeDualSide: true, + noiseControlsWithOneEarbud: true, + noiseReductionAdjustments: true, + noiseReductionLevels: 1, + pairingMode: true, + seamlessConnection: true, + smartThingsFind: true, + spatialSensor: true, + stereoPan: true, + voltage: true, + }, + touchOptions: { + voiceAssistant: 1, + noiseControl: 2, + volume: 3, + spotifySpotOn: 4, + otherL: 5, + otherR: 6, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/galaxyBuds/galaxyBudsConfig.js b/bbm/lib/devices/galaxyBuds/galaxyBudsConfig.js new file mode 100644 index 0000000000..c09726ddb5 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/galaxyBudsConfig.js @@ -0,0 +1,157 @@ +'use strict'; +import GalaxyBuds from './deviceConfigs/GalaxyBuds.js'; +import GalaxyBudsPlus from './deviceConfigs/GalaxyBudsPlus.js'; +import GalaxyBudsLive from './deviceConfigs/GalaxyBudsLive.js'; +import GalaxyBudsPro from './deviceConfigs/GalaxyBudsPro.js'; +import GalaxyBuds2 from './deviceConfigs/GalaxyBuds2.js'; +import GalaxyBudsCore from './deviceConfigs/GalaxyBudsCore.js'; +import GalaxyBuds2Pro from './deviceConfigs/GalaxyBuds2Pro.js'; +import GalaxyBudsFe from './deviceConfigs/GalaxyBudsFe.js'; +import GalaxyBuds3 from './deviceConfigs/GalaxyBuds3.js'; +import GalaxyBuds3Pro from './deviceConfigs/GalaxyBuds3Pro.js'; +import GalaxyBuds3Fe from './deviceConfigs/GalaxyBuds3Fe.js'; +import GalaxyBuds4 from './deviceConfigs/GalaxyBuds4.js'; +import GalaxyBuds4Pro from './deviceConfigs/GalaxyBuds4Pro.js'; + +export const GalaxyBudsModelList = [ + GalaxyBuds, + GalaxyBudsPlus, + GalaxyBudsLive, + GalaxyBudsPro, + GalaxyBuds2, + GalaxyBudsCore, + GalaxyBuds2Pro, + GalaxyBudsFe, + GalaxyBuds3, + GalaxyBuds3Pro, + GalaxyBuds3Fe, + GalaxyBuds4, + GalaxyBuds4Pro, +]; + +export const GalaxyBudsModel = { + Unknown: 0, + GalaxyBuds: 1, + GalaxyBudsPlus: 2, + GalaxyBudsLive: 3, + GalaxyBudsPro: 4, + GalaxyBuds2: 5, + GalaxyBuds2Pro: 6, + GalaxyBudsFe: 7, + GalaxyBuds3: 8, + GalaxyBuds3Pro: 9, + GalaxyBuds3Fe: 10, + GalaxyBudsCore: 11, + GalaxyBuds4: 12, + GalaxyBuds4Pro: 13, +}; + +export const crc16Tab = Uint16Array.from([ + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108, 0x9129, + 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252, + 0x52B5, 0x4294, 0x72F7, 0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, + 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, + 0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, + 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, 0xB75B, 0xA77A, 0x9719, 0x8738, + 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, + 0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, + 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, + 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, + 0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, + 0x8D68, 0x9D49, 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, + 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78, 0x9188, 0x81A9, + 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3, + 0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, + 0xE37F, 0xF35E, 0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, 0x34E2, 0x24C3, + 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB, 0xB7FA, 0x8799, 0x97B8, + 0xE75F, 0xF77E, 0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, + 0x4615, 0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C, + 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37, 0x7A16, + 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, + 0x9DE8, 0x8DC9, 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, + 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, + 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0, +]); + +export const SamsungMepSppUUID = 'f8620674-a1ed-41ab-a8b9-de9ad655729d'; +export const BudsLegacyUUID = '00001102-0000-1000-8000-00805f9b34fd'; +export const DeviceIdPrefixUUID = 'd908aab5-7a90-4cbe-8641-86a553db'; + +export const GalaxyBudsMsgIds = { + DEBUG_SERIAL: 0x29, + UNIVERSAL_MSG_ID_ACKNOWLEDGEMENT: 0X42, + STATUS_UPDATED: 0x60, + EXTENDED_STATUS_UPDATED: 0x61, + SET_ANC_WITH_ONE_EARBUD: 0x6F, + NOISE_CONTROLS_UPDATE: 0x77, + NOISE_CONTROLS: 0x78, + SET_TOUCH_AND_HOLD_NOISE_CONTROLS: 0x79, + SET_DETECT_CONVERSATIONS: 0x7A, + SET_DETECT_CONVERSATIONS_DURATION: 0x7B, + SET_AMBIENT_MODE: 0x80, + AMBIENT_MODE_UPDATED: 0x81, + CUSTOMIZE_AMBIENT_SOUND: 0x82, + NOISE_REDUCTION_LEVEL: 0x83, + AMBIENT_VOLUME: 0x84, + MANAGER_INFO: 0x88, + SET_SIDETONE: 0x8B, + SET_HEARING_ENHANCEMENTS: 0x8F, + LOCK_TOUCHPAD: 0x90, + TOUCH_UPDATED: 0x91, + SET_TOUCHPAD_OPTION: 0x92, + OUTSIDE_DOUBLE_TAP: 0x95, + SET_NOISE_REDUCTION: 0x98, + NOISE_REDUCTION_MODE_UPDATE: 0x9B, + EQUALIZER: 0x56, + FIND_MY_EARBUDS_START: 0xA0, + FIND_MY_EARBUDS_STOP: 0xA1, + FIND_MY_EARBUDS_ON_WEARING_START: 0xA6, + FW_VERSION: 0x68, + CRADLE_SERIAL: 0xCD, +}; + +export const LegacyMsgIds = { + SPP_ROLE_STATE: 0x73, + AMBIENT_VOICE_FOCUS: 0x85, + AMBIENT_WEARING_STATUS_UPDATED: 0x89, + TAP_TEST_MODE_EVENT: 0x8E, +}; + +export const GalaxyBudsMsgTypes = { + Request: 0, + Response: 1, +}; + +export const GalaxyBudsAnc = { + Off: 0, + NoiseReduction: 1, + AmbientSound: 2, + Adaptive: 3, +}; + +export const GalaxyBudsEarDetectionState = { + Disconnected: 0, + Wearing: 1, + Idle: 2, + Case: 3, + ClosedCase: 4, +}; + +export const GalaxyBudsLegacyEarDetectionState = { + None: 0, + R: 1, + L: 16, + Both: 17, +}; + +export const EqPresets = { + BassBoost: 0, + Soft: 1, + Dynamic: 2, + Clear: 3, + TrebleBoost: 4, + Off: 255, // Do not send this +}; + diff --git a/bbm/lib/devices/galaxyBuds/galaxyBudsDetector.js b/bbm/lib/devices/galaxyBuds/galaxyBudsDetector.js new file mode 100644 index 0000000000..44b86eb4f6 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/galaxyBudsDetector.js @@ -0,0 +1,119 @@ +'use strict'; +import {SppUUid} from '../deviceUtils.js'; +import {GalaxyBudsModel, BudsLegacyUUID, DeviceIdPrefixUUID} from './galaxyBudsConfig.js'; + +export function checkForSamsungBuds(uuids, name) { + if (uuids.includes(BudsLegacyUUID)) + return GalaxyBudsModel.GalaxyBuds; + + if (!uuids.includes(SppUUid)) + return null; + + let model = null; + + const coloredGuid = uuids.find(g => g.startsWith(DeviceIdPrefixUUID)); + if (coloredGuid) { + const hexId = coloredGuid.slice(DeviceIdPrefixUUID.length); + if (hexId.length === 4) { + const id = parseInt(hexId, 16); + + switch (id) { + case 0x0101: + model = GalaxyBudsModel.GalaxyBuds; + break; + + case 0x0102: case 0x0103: case 0x0104: case 0x0105: + case 0x0106: case 0x0107: case 0x0108: case 0x0109: + case 0x010A: + model = GalaxyBudsModel.GalaxyBudsPlus; + break; + + case 0x0116: case 0x0117: case 0x0118: + case 0x0119: case 0x011A: case 0x011B: case 0x011C: + model = GalaxyBudsModel.GalaxyBudsLive; + break; + + case 0x012A: case 0x012B: + case 0x012C: case 0x012D: + model = GalaxyBudsModel.GalaxyBudsPro; + break; + + case 0x0139: case 0x013A: case 0x013B: case 0x013C: + case 0x013D: case 0x013E: case 0x013F: case 0x0140: + case 0x0141: case 0x3801: + model = GalaxyBudsModel.GalaxyBuds2; + break; + + case 0x0142: case 0x0143: + model = GalaxyBudsModel.GalaxyBudsCore; + break; + + case 0x0145: case 0x0146: case 0x0147: case 0x0148: + model = GalaxyBudsModel.GalaxyBuds2Pro; + break; + + case 0x014A: case 0x014B: + model = GalaxyBudsModel.GalaxyBudsFe; + break; + + case 0x014D: case 0x014E: + model = GalaxyBudsModel.GalaxyBuds3; + break; + + case 0x0154: case 0x0155: + model = GalaxyBudsModel.GalaxyBuds3Pro; + break; + + case 0x015B: case 0x015C: + model = GalaxyBudsModel.GalaxyBuds3Fe; + break; + + case 0x0163: case 0x0164: + model = GalaxyBudsModel.GalaxyBuds4; + break; + + case 0x0167: case 0x0168: case 0x0169: + model = GalaxyBudsModel.GalaxyBuds4Pro; + break; + } + } + } + + if (!model && name) { + const lower = name.toLowerCase(); + + if (lower.includes('buds4 pro')) + model = GalaxyBudsModel.GalaxyBuds4Pro; + else if (lower.includes('buds3 pro')) + model = GalaxyBudsModel.GalaxyBuds3Pro; + else if (lower.includes('buds2 pro')) + model = GalaxyBudsModel.GalaxyBuds2Pro; + else if (lower.includes('buds pro')) + model = GalaxyBudsModel.GalaxyBudsPro; + + else if (lower.includes('buds3 fe')) + model = GalaxyBudsModel.GalaxyBuds3Fe; + else if (lower.includes('buds fe')) + model = GalaxyBudsModel.GalaxyBudsFe; + + else if (lower.includes('buds4')) + model = GalaxyBudsModel.GalaxyBuds4; + else if (lower.includes('buds3')) + model = GalaxyBudsModel.GalaxyBuds3; + else if (lower.includes('buds2')) + model = GalaxyBudsModel.GalaxyBuds2; + + else if (lower.includes('buds live')) + model = GalaxyBudsModel.GalaxyBudsLive; + else if (lower.includes('buds+') || lower.includes('buds plus')) + model = GalaxyBudsModel.GalaxyBudsPlus; + else if (lower.includes('buds core')) + model = GalaxyBudsModel.GalaxyBudsCore; + } + + if (!model) + return null; + + return model; +} + diff --git a/bbm/lib/devices/galaxyBuds/galaxyBudsDevice.js b/bbm/lib/devices/galaxyBuds/galaxyBudsDevice.js new file mode 100644 index 0000000000..c9a3a1e86a --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/galaxyBudsDevice.js @@ -0,0 +1,1379 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, + SppUUidType, SppUUid +} from '../deviceUtils.js'; +import {GalaxyBudsSocket} from './galaxyBudsSocket.js'; +import {checkForSamsungBuds} from './galaxyBudsDetector.js'; +import { + GalaxyBudsModel, GalaxyBudsModelList, GalaxyBudsAnc, BudsLegacyUUID, + SamsungMepSppUUID, GalaxyBudsEarDetectionState +} from './galaxyBudsConfig.js'; + +export const DeviceTypeGalaxyBuds = 'galaxyBuds'; +export const DeviceTypeGalaxyLegacy = 'galaxybudslegacy'; + +export function isGalaxyLegacy(bluezDeviceProxy, uuids) { + const bluezProps = []; + let supported = 'no'; + + if (uuids.includes(SamsungMepSppUUID) && uuids.includes(BudsLegacyUUID)) + supported = 'yes'; + + return {supported, bluezProps}; +} + +export function isGalaxyBuds(bluezDeviceProxy, uuids) { + const bluezProps = []; + let supported = 'no'; + + if (uuids.includes(SamsungMepSppUUID) && uuids.includes(SppUUid)) { + const name = bluezDeviceProxy.Name; + if (checkForSamsungBuds(uuids, name)) + supported = 'yes'; + } + + return {supported, bluezProps}; +} + +export const GalaxyBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GalaxyBudsDevice', +}, class GalaxyBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GalaxyBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- GalaxyBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._battInfoRecieved = false; + this._toggle1Enabled = false; + this._toggle2Enabled = false; + this._bothInEars = false; + this._budInEar = false; + this._ancMode = 0; + this._ambientLevel = 0; + this._ncLevel = 0; + this._focusOnVoice = false; + this._fwVersion = ''; + this._leftSerial = ''; + this._rightSerial = ''; + this._caseSerial = ''; + + this._config = createConfig(); + this._props = createProperties(); + + this._initialize(profileManager); + } + + _initialize(profileManager) { + const bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const uuids = bluezDeviceProxy.UUIDs; + const name = bluezDeviceProxy.Name; + + this._modelId = checkForSamsungBuds(uuids, name); + if (!this._modelId) { + this._log.info('No valid modelId found'); + return; + } + this._log.info(`Model id: ${this._modelId}`); + + const modelData = GalaxyBudsModelList.find(m => m.modelId === this._modelId); + + if (!modelData) { + this._log.info(`No matching modelData found for name: ${name}`); + return; + } + + this._log.info(`Configuration: ${JSON.stringify(modelData, null, 2)}`); + + this._features = modelData.features; + this._touchOptions = modelData.touchOptions; + + + this._callbacks = { + updateFirmwareInfo: this.updateFirmwareInfo.bind(this), + updateLRSerial: this.updateLRSerial.bind(this), + updateCaseSerial: this.updateCaseSerial.bind(this), + updateExtendedStatusStarted: this.updateExtendedStatusStarted.bind(this), + updateExtendedStatusEnded: this.updateExtendedStatusEnded.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateInEarState: this.updateInEarState.bind(this), + + updateFocusOnVoice: this.updateFocusOnVoice.bind(this), + updateAmbientVolume: this.updateAmbientVolume.bind(this), + updateNCModes: this.updateNCModes.bind(this), + + updateEqPresets: this.updateEqPresets.bind(this), + updateTouchpadLock: this.updateTouchpadLock.bind(this), + updateTouchpadOptionL: this.updateTouchpadOptionL.bind(this), + updateTouchpadOptionR: this.updateTouchpadOptionR.bind(this), + updateAdvanceTouchpadLock: this.updateAdvanceTouchpadLock.bind(this), + updateSideToneEnabled: this.updateSideToneEnabled.bind(this), + updateStereoBal: this.updateStereoBal.bind(this), + updateNoiseReductionLevel: this.updateNoiseReductionLevel.bind(this), + updateNoiseControlCycle: this.updateNoiseControlCycle.bind(this), + updateDetectConversations: this.updateDetectConversations.bind(this), + updateDetectConversationsDuration: this.updateDetectConversationsDuration.bind(this), + updateNoiseControlsWithOneEarbud: this.updateNoiseControlsWithOneEarbud.bind(this), + updateOutsideDoubleTap: this.updateOutsideDoubleTap.bind(this), + updateLightingMode: this.updateLightingMode.bind(this), + updateAmbientCustomization: this.updateAmbientCustomization.bind(this), + }; + + this._commonIcon = modelData.budsIcon; + if (this._features.caseBattery) + this._caseIcon = `${modelData.case}`; + + this._createDefaultSettings(); + + const devicesList = this._settings.get_strv('galaxy-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'galaxy-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorGalaxyBudsListGsettings(true); + this._updateIcons(); + + this._configureANC(); + this._configureDetectConversations(); + this._config.showSettingsButton = true; + + this._ringState = 'stopped'; + this._settingsItems['ring-state'] = this._ringState; + this._updateGsettings(); + + const type = this._modelId === GalaxyBudsModel.GalaxyBuds + ? DeviceTypeGalaxyLegacy : SppUUidType; + + const uuid = type === DeviceTypeGalaxyLegacy ? BudsLegacyUUID : SppUUid; + + const profile = {type, uuid}; + + this._galaxyBudsSocket = new GalaxyBudsSocket( + this._devicePath, + profileManager, + profile, + modelData, + this._callbacks + ); + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery1ShowOnDisconnect = true; + this._config.battery2ShowOnDisconnect = true; + + if (this._features.caseBattery) + this._config.battery3Icon = this._caseIcon; + + this.dataHandler?.setConfig(this._config); + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + modelId: this._modelId, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': this._fwVersion, + 'lsn': this._leftSerial, + 'rsn': this._rightSerial, + 'csn': this._caseSerial, + + ...this._features.caseBattery && { + 'case': this._caseIcon, + }, + + 'wear-detection-mode': 1, + + ...this._features.detectConversations && { + 's2c-time': 0, + }, + + 'eq-preset': 0, + + ...this._features.stereoPan && { + 'stereo-bal': 0, + }, + + 'tp-enabled': false, + + ...this._features.advancedTouchLock && { + 'tp-adv-single': false, + 'tp-adv-double': false, + 'tp-adv-triple': false, + 'tp-adv-hold': false, + }, + + ...this._features.advancedTouchIsPinch && { + 'tp-adv-swipe': false, + }, + + ...this._features.advancedTouchLockForCalls && { + 'tp-adv-call-double': false, + 'tp-adv-call-hold': false, + }, + + ...this._features.lightingControl && { + 'tp-lighting': 0, + }, + + 'th-left': 0, + 'th-right': 0, + + ...this._features.ncCycle && { + 'nc-off': false, + 'nc-ambient': false, + 'nc-anc': false, + ...this._features.adaptiveNoiseControl && { + 'nc-adaptive': false, + }, + + ...this._features.noiseControlModeDualSide && { + 'nc-left-off': false, + 'nc-left-ambient': false, + 'nc-left-anc': false, + ...this._features.adaptiveNoiseControl && { + 'nc-left-adaptive': false, + }, + }, + }, + + ...this._features.ambientSidetone && { + 'sidetone': false, + }, + + ...this._features.noiseControlsWithOneEarbud && { + 'nc-one': false, + }, + + ...this._features.doubleTapVolume && { + '2tap-vol': false, + }, + + ...this._features.ambientCustomize && { + 'amb-enable': false, + 'amb-left': 0, + 'amb-right': 0, + 'amb-tone': 0, + }, + + 'ring-state': 'stopped', + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('galaxy-buds-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('galaxy-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._features.caseBattery) + this._caseIcon = this._settingsItems['case']; + + if (this._features.detectConversations) + this._s2cTime = this._settingsItems['s2c-time']; + + this._eqPreset = this._settingsItems['eq-preset']; + + if (this._features.stereoPan) + this._stereoBal = this._settingsItems['stereo-bal']; + + this._touchEnabled = this._settingsItems['tp-enabled']; + + if (this._features.advancedTouchLock) { + this._tpAdvLock = this._settingsItems['tp-adv-lock']; + this._tpAdvSingle = this._settingsItems['tp-adv-single']; + this._tpAdvDouble = this._settingsItems['tp-adv-double']; + this._tpAdvTriple = this._settingsItems['tp-adv-triple']; + this._tpAdvHold = this._settingsItems['tp-adv-hold']; + } + + if (this._features.advancedTouchLockForCalls) { + this._tpAdvCallDouble = this._settingsItems['tp-adv-call-double']; + this._tpAdvCallHold = this._settingsItems['tp-adv-call-hold']; + } + + if (this._features.lightingControl) + this._tpLighting = this._settingsItems['tp-lighting']; + + this._thLeft = this._settingsItems['th-left']; + this._thRight = this._settingsItems['th-right']; + + if (this._features.noiseControl) { + this._ncCycleRight = this._settingsItems['nc-cycle-right']; + + if (this._features.noiseControlModeDualSide) + this._ncCycleLeft = this._settingsItems['nc-cycle-left']; + } + + if (this._features.ambientSidetone) + this._sidetone = this._settingsItems['sidetone']; + + + if (this._features.noiseControlsWithOneEarbud) + this._ncOne = this._settingsItems['nc-one']; + + + if (this._features.doubleTapVolume) + this._twoTapVol = this._settingsItems['2tap-vol']; + + if (this._features.ambientCustomize) { + this._ambEnable = this._settingsItems['amb-enable']; + this._ambLeft = this._settingsItems['amb-left']; + this._ambRight = this._settingsItems['amb-right']; + this._ambTone = this._settingsItems['amb-tone']; + } + + this._wearDetectionMode = this._settingsItems['wear-detection-mode']; + this._ringState = 'stopped'; + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('galaxy-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._features.caseBattery) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + const wearDetectionMode = this._settingsItems['wear-detection-mode']; + if (this._wearDetectionMode !== wearDetectionMode) { + this._wearDetectionMode = wearDetectionMode; + this._configureMediaController(); + } + + if (this._features.detectConversations) { + const s2cTime = this._settingsItems['s2c-time']; + if (this._s2cTime !== s2cTime) { + this._s2cTime = s2cTime; + this._setDetectConversationConfig(); + } + } + + const eqPreset = this._settingsItems['eq-preset']; + if (this._eqPreset !== eqPreset) { + this._eqPreset = eqPreset; + this._setEqPreset(); + } + + if (this._features.stereoPan) { + const stereoBal = this._settingsItems['stereo-bal']; + if (this._stereoBal !== stereoBal) { + this._stereoBal = stereoBal; + this._setStereoBalance(); + } + } + + const tpLock = this._settingsItems['tp-enabled']; + if (this._touchEnabled !== tpLock) { + this._touchEnabled = tpLock; + if (this._features.advancedTouchLock || this._features.advancedTouchIsPinch) + this._setAdvancedTouchConfig(); + else + this._setTouchpadLock(); + } + + if (this._features.advancedTouchLock) { + const tpAdvSingle = this._settingsItems['tp-adv-single']; + if (this._tpAdvSingle !== tpAdvSingle) { + this._tpAdvSingle = tpAdvSingle; + this._setAdvancedTouchConfig(); + } + + const tpAdvDouble = this._settingsItems['tp-adv-double']; + if (this._tpAdvDouble !== tpAdvDouble) { + this._tpAdvDouble = tpAdvDouble; + this._setAdvancedTouchConfig(); + } + + const tpAdvTriple = this._settingsItems['tp-adv-triple']; + if (this._tpAdvTriple !== tpAdvTriple) { + this._tpAdvTriple = tpAdvTriple; + this._setAdvancedTouchConfig(); + } + + const tpAdvHold = this._settingsItems['tp-adv-hold']; + if (this._tpAdvHold !== tpAdvHold) { + this._tpAdvHold = tpAdvHold; + this._setAdvancedTouchConfig(); + } + } + + if (this._features.advancedTouchLockForCalls) { + const tpAdvCallDouble = this._settingsItems['tp-adv-call-double']; + if (this._tpAdvCallDouble !== tpAdvCallDouble) { + this._tpAdvCallDouble = tpAdvCallDouble; + this._setAdvancedTouchConfig(); + } + + const tpAdvCallHold = this._settingsItems['tp-adv-call-hold']; + if (this._tpAdvCallHold !== tpAdvCallHold) { + this._tpAdvCallHold = tpAdvCallHold; + this._setAdvancedTouchConfig(); + } + } + + if (this._features.lightingControl) { + const tpLighting = this._settingsItems['tp-lighting']; + if (this._tpLighting !== tpLighting) { + this._tpLighting = tpLighting; + this._setAdvancedTouchConfig(); + } + } + + const thLeft = this._settingsItems['th-left']; + if (this._thLeft !== thLeft) { + this._thLeft = thLeft; + this._setTouchAndHold(); + } + + const thRight = this._settingsItems['th-right']; + if (this._thRight !== thRight) { + this._thRight = thRight; + this._setTouchAndHold(); + } + + if (this._features.noiseControl) { + const ncCycleRight = this._settingsItems['nc-cycle-right']; + if (this._ncCycleRight !== ncCycleRight) { + this._ncCycleRight = ncCycleRight; + this._setNoiseControlConfig(); + } + + if (this._features.noiseControlModeDualSide) { + const ncCycleLeft = this._settingsItems['nc-cycle-left']; + if (this._ncCycleLeft !== ncCycleLeft) { + this._ncCycleLeft = ncCycleLeft; + this._setNoiseControlConfig(); + } + } + } + + if (this._features.ambientSidetone) { + const sidetone = this._settingsItems['sidetone']; + if (this._sidetone !== sidetone) { + this._sidetone = sidetone; + this._setAmbientSidetone(); + } + } + + if (this._features.noiseControlsWithOneEarbud) { + const ncOne = this._settingsItems['nc-one']; + if (this._ncOne !== ncOne) { + this._ncOne = ncOne; + this._configureANC(); + this._setNcOneEarbud(); + } + } + + if (this._features.doubleTapVolume) { + const twoTapVol = this._settingsItems['2tap-vol']; + if (this._twoTapVol !== twoTapVol) { + this._twoTapVol = twoTapVol; + this._setDoubleTapVolume(); + } + } + + if (this._features.ambientCustomize) { + const ambEnable = this._settingsItems['amb-enable']; + if (this._ambEnable !== ambEnable) { + this._ambEnable = ambEnable; + this._setAmbientCustomize(); + } + + const ambLeft = this._settingsItems['amb-left']; + if (this._ambLeft !== ambLeft) { + this._ambLeft = ambLeft; + this._setAmbientCustomize(); + } + + const ambRight = this._settingsItems['amb-right']; + if (this._ambRight !== ambRight) { + this._ambRight = ambRight; + this._setAmbientCustomize(); + } + + const ambTone = this._settingsItems['amb-tone']; + if (this._ambTone !== ambTone) { + this._ambTone = ambTone; + this._setAmbientCustomize(); + } + } + + const state = this._settingsItems['ring-state']; + + if (this._ringState !== state) { + this._ringState = state; + this._setRingMyBuds(state); + } + } + + _monitorGalaxyBudsListGsettings(monitor) { + if (monitor) { + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + + this._settingsHandlerId = this._settings?.connect('changed::galaxy-buds-list', () => + this._updateGsettingsProps()); + } else { + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + } + } + + _updateGsettings() { + this._monitorGalaxyBudsListGsettings(false); + + const currentList = this._settings.get_strv('galaxy-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('galaxy-buds-list', currentList.map(JSON.stringify)); + } + + this._monitorGalaxyBudsListGsettings(true); + } + + _configureANC() { + if (!this._features.ambientSound && !this._features.noiseCancellation) + return; + + this._toggle1Enabled = true; + + this._config.toggle1Title = _('Noise Control'); + + let showAnc = false; + if (this._features.noiseCancellation) { + showAnc = this._features.noiseControlsWithOneEarbud + ? this._ncOne || this._bothInEars : true; + } + + let showAdaptive = false; + if (this._features.adaptiveNoiseControl) { + showAdaptive = this._features.noiseControlsWithOneEarbud + ? this._ncOne || this._bothInEars : true; + } + + const modes = ['off']; + + if (this._features.ambientSound) + modes.push('transparency'); + + if (showAdaptive) + modes.push('adaptive'); + + if (showAnc) + modes.push('anc'); + + this._toggle1Modes = modes; + + const icons = { + off: 'bbm-anc-off-symbolic', + transparency: 'bbm-transperancy-symbolic', + adaptive: 'bbm-adaptive-symbolic', + anc: 'bbm-anc-on-symbolic', + }; + + const labels = { + off: _('Off'), + transparency: _('Transparency'), + adaptive: _('Adaptive'), + anc: _('Noise Cancellation'), + }; + + const ncModes = { + off: GalaxyBudsAnc.Off, + transparency: GalaxyBudsAnc.AmbientSound, + adaptive: GalaxyBudsAnc.Adaptive, + anc: GalaxyBudsAnc.NoiseReduction, + }; + + this._toggle1ButtonToAncMode = []; + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Icon`] = ''; + this._config[`toggle1Button${i}Name`] = ''; + this._toggle1ButtonToAncMode[i] = null; + } + + modes.forEach((mode, index) => { + const button = index + 1; + + this._config[`toggle1Button${button}Icon`] = icons[mode]; + this._config[`toggle1Button${button}Name`] = labels[mode]; + + this._toggle1ButtonToAncMode[button] = ncModes[mode]; + }); + + this._featuresAmbientVolume = this._features.ambientSoundVolume && + this._features.ambientVolumeMax; + + this._featuresNCVolume = this._features.noiseReductionAdjustments && + this._features.noiseReductionLevels; + + if (this._featuresAmbientVolume && this._featuresNCVolume) { + this._config.optionsBox1 = ['slider']; + this._config.box1SliderTitle = _('Ambient Level'); + this._config.optionsBox2 = ['slider']; + this._config.box2SliderTitle = _('Noise Cancellation Level'); + } else if (this._featuresAmbientVolume) { + if (this._features.ambientVoiceFocus) { + this._config.optionsBox1 = ['check-button', 'slider']; + this._config.box1CheckButton = [_('Focus on Voice')]; + } else { + this._config.optionsBox1 = ['slider']; + } + this._config.box1SliderTitle = _('Ambient Level'); + } else if (this._featuresNCVolume) { + this._config.optionsBox1 = ['slider']; + this._config.box1SliderTitle = _('Noise Cancellation Level'); + } + + this.dataHandler?.setConfig(this._config); + } + + _configureDetectConversations() { + if (!this._features.detectConversations) + return; + + this._toggle2Enabled = true; + this._config.toggle2Title = _('Conversation Awareness'); + this._config.toggle2Button1Icon = 'bbm-ca-on-symbolic'; + this._config.toggle2Button1Name = _('On'); + this._config.toggle2Button2Icon = 'bbm-ca-off-symbolic'; + this._config.toggle2Button2Name = _('Off'); + } + + updateExtendedStatusStarted() { + this._monitorGalaxyBudsListGsettings(false); + } + + updateExtendedStatusEnded() { + this._monitorGalaxyBudsListGsettings(true); + this._updateGsettings(); + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + this._configureMediaController(); + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'toggle2State') + this._toggle2ButtonClicked(value); + + if (command === 'box1SliderValue') + this._box1SliderValueChanged(value); + + if (command === 'box2SliderValue') + this._box2SliderValueChanged(value); + + if (command === 'box1CheckButton1State') + this._box1CheckButton1StateChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + _configureMediaController() { + // MediaController not available in daemon context + } + + updateFirmwareInfo(fwVersion) { + this._fwVersion = fwVersion; + if (this._settingsItems) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + } + + updateLRSerial(left, right) { + this._leftSerial = left; + this._rightSerial = right; + if (this._settingsItems) { + this._settingsItems['lsn'] = left; + this._settingsItems['rsn'] = right; + this._updateGsettings(); + } + } + + updateCaseSerial(caseSn) { + this._caseSerial = caseSn; + if (this._settingsItems) { + this._settingsItems['csn'] = caseSn; + this._updateGsettings(); + } + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + if (!this._battInfoRecieved) + this._startConfiguration(props); + + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + this.dataHandler?.setProps(this._props); + } + + updateInEarState(left, right) { + let bothInEarChanged = false; + let budsInEarChanged = false; + const bothInEars = left === GalaxyBudsEarDetectionState.Wearing && + right === GalaxyBudsEarDetectionState.Wearing; + + const budInEar = left === GalaxyBudsEarDetectionState.Wearing || + right === GalaxyBudsEarDetectionState.Wearing; + + if (this._bothInEars !== bothInEars) { + this._bothInEars = bothInEars; + bothInEarChanged = true; + } + + if (this._budInEar !== budInEar) { + this._budInEar = budInEar; + budsInEarChanged = true; + } + + if (budsInEarChanged || bothInEarChanged) { + let toggle1Visible = false; + if (this._features.noiseControlsWithOneEarbud) + toggle1Visible = this._budInEar; + else + toggle1Visible = this._bothInEars; + + + this._props.toggle1Visible = this._toggle1Enabled && toggle1Visible; + this._props.toggle2Visible = this._toggle2Enabled && this._bothInEars; + this.dataHandler?.setProps(this._props); + + if (this._features.noiseControlsWithOneEarbud && !this._ncOne && bothInEarChanged) + this._configureANC(); + } + + if (this._wearDetectionMode !== 0) { + let playbackMode = null; + + if (this._wearDetectionMode === 1) + playbackMode = bothInEars ? 'play' : 'pause'; + else if (this._wearDetectionMode === 2) + playbackMode = budInEar ? 'play' : 'pause'; + + if (playbackMode) + this._mediaController?.changeActivePlayerState(playbackMode); + } + } + + _updateOptionBox(mode) { + if (mode === GalaxyBudsAnc.Off) { + this._props.optionsBoxVisible = 0; + } else if (mode === GalaxyBudsAnc.AmbientSound) { + if (this._featuresAmbientVolume) + this._props.optionsBoxVisible = 1; + } else if (mode === GalaxyBudsAnc.NoiseReduction) { + if (this._featuresNCVolume) + this._props.optionsBoxVisible = this._featuresAmbientVolume ? 2 : 1; + } else if (mode === GalaxyBudsAnc.Adaptive) { + this._props.optionsBoxVisible = 0; + } + } + + updateNCModes(mode) { + this._ancMode = mode; + + let index = this._toggle1ButtonToAncMode?.findIndex(v => v === mode); + + if (index < 0) + index = 0; + + this._props.toggle1State = index; + this._updateOptionBox(mode); + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + const ancMode = this._toggle1ButtonToAncMode?.[index]; + if (ancMode == null) + return; + + this._updateOptionBox(ancMode); + + this._props.toggle1State = index; + this.dataHandler?.setProps(this._props); + + if (this._features.noiseControl) + this._galaxyBudsSocket.setNCModes(ancMode); + else if (this._features.ambientSound) + this._galaxyBudsSocket.setAmbientSoundOnOff(ancMode); + else if (this._features.noiseCancellation) + this._galaxyBudsSocket.setNCOnOff(ancMode); + } + + updateFocusOnVoice(enabled) { + if (!this._features.ambientVoiceFocus) + return; + + if (this._focusOnVoice !== enabled) { + this._focusOnVoice = enabled; + const value = enabled ? 1 : 0; + this._props.box1CheckButton1State = value; + this.dataHandler?.setProps(this._props); + } + } + + _box1CheckButton1StateChanged(value) { + if (!this._features.ambientVoiceFocus) + return; + + const enabled = value === 1; + + if (this._focusOnVoice !== enabled) { + this._focusOnVoice = enabled; + this._galaxyBudsSocket.setFocusOnVoice(enabled); + }; + } + + updateAmbientVolume(level) { + if (!this._featuresAmbientVolume) + return; + + if (this._ambientLevel !== level) { + this._ambientLevel = level; + const maxVolume = this._features.ambientVolumeMax; + this._props.box1SliderValue = Math.round(level * 100 / maxVolume); + this.dataHandler?.setProps(this._props); + } + } + + updateNoiseReductionLevel(level) { + if (!this._featuresNCVolume) + return; + + if (this._ncLevel !== level) { + this._ncLevel = level; + const maxVolume = this._features.noiseReductionLevels; + const value = Math.round(level * 100 / maxVolume); + if (this._featuresAmbientVolume) + this._props.box2SliderValue = value; + else + this._props.box1SliderValue = value; + this.dataHandler?.setProps(this._props); + } + } + + _box1SliderValueChanged(value) { + if (this._featuresAmbientVolume) { + const maxVolume = this._features.ambientVolumeMax; + const level = Math.round(value * maxVolume / 100); + if (this._ambientLevel !== level) { + this._ambientLevel = level; + this._galaxyBudsSocket.setAmbientVolume(level); + } + } else if (this._featuresNCVolume) { + const maxVolume = this._features.noiseReductionLevels; + const level = Math.round(value * maxVolume / 100); + if (this._ncLevel !== level) { + this._ncLevel = level; + this._galaxyBudsSocket.setNoiseCancellationLevel(level); + } + } + } + + _box2SliderValueChanged(value) { + if (this._featuresNCVolume && this._featuresAmbientVolume) { + const maxVolume = this._features.noiseReductionLevels; + const level = Math.round(value * maxVolume / 100); + if (this._ncLevel !== level) { + this._ncLevel = level; + this._galaxyBudsSocket.setNoiseCancellationLevel(level); + } + } + } + + updateDetectConversations(enabled) { + if (!this._features.detectConversations) + return; + + if (this._s2cenable !== enabled) { + this._s2cenable = enabled; + this._props.toggle1State = enabled ? 1 : 2; + this.dataHandler?.setProps(this._props); + } + } + + _toggle2ButtonClicked(value) { + if (!this._features.detectConversations) + return; + + const enabled = value === 1; + if (this._s2cenable !== enabled) { + this._s2cenable = enabled; + this._galaxyBudsSocket.setDetectConversations(enabled); + } + this._props.toggle2State = value; + this.dataHandler?.setProps(this._props); + } + + updateDetectConversationsDuration(duration) { + if (!this._features.detectConversations) + return; + + if (this._s2cTime !== duration) { + this._s2cTime = duration; + this._settingsItems['s2c-time'] = duration; + this._updateGsettings(); + } + } + + _setDetectConversationConfig() { + if (!this._features.detectConversations) + return; + + this._galaxyBudsSocket.setDetectConversationsDuration(this._s2cTime); + } + + updateEqPresets(presetCode) { + if (this._eqPreset !== presetCode) { + this._eqPreset = presetCode; + this._settingsItems['eq-preset'] = presetCode; + this._updateGsettings(); + } + } + + _setEqPreset() { + this._galaxyBudsSocket.setEqPresets(this._eqPreset); + } + + updateStereoBal(level) { + if (!this._features.stereoPan) + return; + + if (this._stereoBal !== level) { + this._stereoBal = level; + this._settingsItems['stereo-bal'] = level; + this._updateGsettings(); + } + } + + _setStereoBalance() { + if (!this._features.stereoPan) + return; + + this._galaxyBudsSocket.setStereoBalance(this._stereoBal); + } + + updateTouchpadLock(lockEnabled) { + const touchEnabled = !lockEnabled; + if (this._touchEnabled !== touchEnabled) { + this._touchEnabled = touchEnabled; + this._settingsItems['tp-enabled'] = touchEnabled; + this._updateGsettings(); + } + } + + _setTouchpadLock() { + const lockEnabled = !this._touchEnabled; + this._galaxyBudsSocket.setTouchPadLock(lockEnabled); + } + + updateAdvanceTouchpadLock(touchProps) { + if (!this._features.advancedTouchLock && !this._features.advancedTouchIsPinch) + return; + + let update = false; + + if (this._features.advancedTouchLock) { + const touchEnabled = !touchProps.touchpadLock; + const single = touchProps.singleTapOn; + const dbl = touchProps.doubleTapOn; + const triple = touchProps.tripleTapOn; + const hold = touchProps.touchHoldOn; + + if (this._touchEnabled !== touchEnabled) { + this._touchEnabled = touchEnabled; + this._settingsItems['tp-enabled'] = touchEnabled; + update = true; + } + + if (this._tpAdvSingle !== single) { + this._tpAdvSingle = single; + this._settingsItems['tp-adv-single'] = single; + update = true; + } + + if (this._tpAdvDouble !== dbl) { + this._tpAdvDouble = dbl; + this._settingsItems['tp-adv-double'] = dbl; + update = true; + } + + if (this._tpAdvTriple !== triple) { + this._tpAdvTriple = triple; + this._settingsItems['tp-adv-triple'] = triple; + update = true; + } + + if (this._tpAdvHold !== hold) { + this._tpAdvHold = hold; + this._settingsItems['tp-adv-hold'] = hold; + update = true; + } + } else { + const single = touchProps.singleTapOn; + const dbl = touchProps.doubleTapOn; + const triple = touchProps.tripleTapOn; + + const mediaEnabled = single && dbl && triple; + if (this._touchEnabled !== mediaEnabled) { + this._touchEnabled = mediaEnabled; + this._settingsItems['tp-enabled'] = mediaEnabled; + update = true; + } + } + + if (this._features.advancedTouchLockForCalls) { + const callDouble = touchProps.doubleTapForCallOn; + const callHold = touchProps.touchHoldOnForCallOff; + + if (this._tpAdvCallDouble !== callDouble) { + this._tpAdvCallDouble = callDouble; + this._settingsItems['tp-adv-call-double'] = callDouble; + update = true; + } + + if (this._tpAdvCallHold !== callHold) { + this._tpAdvCallHold = callHold; + this._settingsItems['tp-adv-call-hold'] = callHold; + update = true; + } + } + + if (this._features.lightingControl && touchProps.lightingMode) { + const lighting = touchProps.lightingMode; + + if (this._tpLighting !== lighting) { + this._tpLighting = lighting; + this._settingsItems['tp-lighting'] = lighting; + update = true; + } + } + + if (update) + this._updateGsettings(); + } + + updateLightingMode(lighting) { + if (this._features.lightingControl) { + if (this._tpLighting !== lighting) { + this._tpLighting = lighting; + this._settingsItems['tp-lighting'] = lighting; + } + } + } + + _setAdvancedTouchConfig() { + if (!this._features.advancedTouchLock && !this._features.advancedTouchIsPinch) + return; + + const props = {}; + if (this._features.advancedTouchLock) { + props.touchpadLock = !this._touchEnabled; + props.singleTapOn = this._tpAdvSingle; + props.doubleTapOn = this._tpAdvDouble; + props.tripleTapOn = this._tpAdvTriple; + props.touchHoldOn = this._tpAdvHold; + } else { + props.touchpadLock = true; + props.singleTapOn = this._touchEnabled; + props.doubleTapOn = this._touchEnabled; + props.tripleTapOn = this._touchEnabled; + props.touchHoldOn = true; + } + + if (this._features.advancedTouchLockForCalls) { + props.doubleTapForCallOn = this._tpAdvCallDouble; + props.touchHoldOnForCallOff = this._tpAdvCallHold; + } + + if (this._features.lightingControl) + props.lightingMode = this._tpLighting; + + this._galaxyBudsSocket.setTouchPadAdvance(props); + } + + updateTouchpadOptionL(leftMode) { + if (!this._features.touchAndHold) + return; + + if (this._thLeft !== leftMode) { + this._thLeft = leftMode; + this._settingsItems['th-left'] = leftMode; + this._updateGsettings(); + } + } + + updateTouchpadOptionR(rightMode) { + if (!this._features.touchAndHold) + return; + + if (this._thRight !== rightMode) { + this._thRight = rightMode; + this._settingsItems['th-right'] = rightMode; + this._updateGsettings(); + } + } + + _setTouchAndHold() { + const props = {left: this._thLeft, right: this._thRight}; + this._galaxyBudsSocket.setTouchAndHoldLRModes(props); + } + + updateNoiseControlCycle(props) { + if (!this._features.noiseControl) + return; + + const rightVal = + (props.off ? 1 : 0) << 0 | + (props.ambient ? 1 : 0) << 1 | + (props.anc ? 1 : 0) << 2 | + (this._features.adaptiveNoiseControl ? (props.adaptive ? 1 : 0) << 3 : 0); + + const leftVal = + !this._features.noiseControlModeDualSide ? rightVal + : (props.leftOff ? 1 : 0) << 0 | + (props.leftAmbient ? 1 : 0) << 1 | + (props.leftAnc ? 1 : 0) << 2 | + (this._features.adaptiveNoiseControl ? (props.leftAdaptive ? 1 : 0) << 3 : 0); + + let update = false; + + if (this._ncCycleRight !== rightVal) { + this._ncCycleRight = rightVal; + this._settingsItems['nc-cycle-right'] = rightVal; + update = true; + } + + if (this._ncCycleLeft !== leftVal) { + this._ncCycleLeft = leftVal; + this._settingsItems['nc-cycle-left'] = leftVal; + update = true; + } + + if (update) + this._updateGsettings(); + } + + + _setNoiseControlConfig() { + if (!this._features.noiseControl) + return; + + const right = this._ncCycleRight; + const left = this._features.noiseControlModeDualSide ? this._ncCycleLeft : null; + + const payload = {right, left}; + + if (this._features.noiseTouchAndHoldNewVersion) + this._galaxyBudsSocket.setNcCycle(payload); + else + this._galaxyBudsSocket.setNcCycleLegacy(payload); + } + + + updateSideToneEnabled(level) { + if (!this._features.ambientSidetone) + return; + + if (this._sidetone !== level) { + this._sidetone = level; + this._settingsItems['sidetone'] = level; + this._updateGsettings(); + } + } + + _setAmbientSidetone() { + if (!this._features.ambientSidetone) + return; + + this._galaxyBudsSocket.setSideTone(this._sidetone); + } + + updateNoiseControlsWithOneEarbud(enabled) { + if (!this._features.noiseControlsWithOneEarbud) + return; + + if (this._ncOne !== enabled) { + this._ncOne = enabled; + this._settingsItems['nc-one'] = enabled; + this._updateGsettings(); + } + } + + _setNcOneEarbud() { + if (!this._features.noiseControlsWithOneEarbud) + return; + + this._galaxyBudsSocket.setNoiseControlsWithOneEarbud(!!this._ncOne); + } + + + + updateOutsideDoubleTap(enabled) { + if (!this._features.doubleTapVolume) + return; + + if (this._twoTapVol !== enabled) { + this._twoTapVol = enabled; + this._settingsItems['2tap-vol'] = enabled; + this._updateGsettings(); + } + } + + _setDoubleTapVolume() { + if (!this._features.doubleTapVolume) + return; + + this._galaxyBudsSocket.setOutsideDoubleTap(!!this._twoTapVol); + } + + updateAmbientCustomization(props) { + if (!this._features.ambientCustomize) + return; + + let update = false; + + if (this._ambEnable !== props.enable) { + this._ambEnable = props.enable; + this._settingsItems['amb-enable'] = props.enable; + update = true; + } + + if (this._ambLeft !== props.leftVolume) { + this._ambLeft = props.leftVolume; + this._settingsItems['amb-left'] = props.leftVolume; + update = true; + } + + if (this._ambRight !== props.rightVolume) { + this._ambRight = props.rightVolume; + this._settingsItems['amb-right'] = props.rightVolume; + update = true; + } + + if (this._ambTone !== props.soundtone) { + this._ambTone = props.soundtone; + this._settingsItems['amb-tone'] = props.soundtone; + update = true; + } + + if (update) + this._updateGsettings(); + } + + _setAmbientCustomize() { + if (!this._features.ambientCustomize) + return; + + const props = { + enable: this._ambEnable, + leftVolume: this._ambLeft, + rightVolume: this._ambRight, + soundtone: this._ambTone, + }; + + this._galaxyBudsSocket.setCustomizeAmbientSound(props); + } + + _setRingMyBuds(state) { + this._galaxyBudsSocket?.setRingMyBuds(state); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'galaxyBuds', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._galaxyBudsSocket?.destroy(); + this._galaxyBudsSocket = null; + this._bluezDeviceProxy = null; + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + this._mediaController?.destroy(); + this._mediaController = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + + diff --git a/bbm/lib/devices/galaxyBuds/galaxyBudsSocket.js b/bbm/lib/devices/galaxyBuds/galaxyBudsSocket.js new file mode 100644 index 0000000000..808bdb8ae4 --- /dev/null +++ b/bbm/lib/devices/galaxyBuds/galaxyBudsSocket.js @@ -0,0 +1,907 @@ +'use strict'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {booleanFromByte, isValidByte} from '../deviceUtils.js'; +import { + crc16Tab, + GalaxyBudsModel, + GalaxyBudsMsgIds, + LegacyMsgIds, + GalaxyBudsMsgTypes, + GalaxyBudsAnc, + GalaxyBudsEarDetectionState, + GalaxyBudsLegacyEarDetectionState, + EqPresets +} from './galaxyBudsConfig.js'; + +const AndroidSdkVersion = 34; +const ClientSamsungDevice = 1; + +export const GalaxyBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GalaxyBudsSocket', +}, class GalaxyBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, modelData, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GalaxyBudsSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('GalaxyBudsSocket init'); + + this._modelId = modelData.modelId; + this._features = modelData.features; + this._firstExtendedStatusRecieved = false; + + const SOM_BUDS = 0xFE; + const EOM_BUDS = 0xEE; + const SOM_BUDS_PLUS = 0xFD; + const EOM_BUDS_PLUS = 0xDD; + + this._startOfMessage = SOM_BUDS_PLUS; + this._endOfMessage = EOM_BUDS_PLUS; + + if (this._modelId === GalaxyBudsModel.GalaxyBuds) { + this._startOfMessage = SOM_BUDS; + this._endOfMessage = EOM_BUDS; + } + + this._callbacks = callbacks; + + this.startSocket(); + } + + _checksum(data) { + let crc = 0; + for (const b of data) + crc = (crc16Tab[(crc >> 8 ^ b) & 0xFF] ^ crc << 8) & 0xFFFF; + + return crc; + } + + _checksumMsg(msgId, payload) { + const data = [msgId, ...payload]; + const crc = this._checksum(data); + return [crc & 0xFF, crc >> 8]; + } + + _decode(buffer) { + if (buffer.length < 6) { + this._log.error(`buffer length too short: ${buffer.length}`); + return null; + } + + if (buffer[0] !== this._startOfMessage || + buffer[buffer.length - 1] !== this._endOfMessage) { + const som = buffer[0].toString(16); + const eom = buffer[buffer.length - 1].toString(16); + const expectedSom = this._startOfMessage.toString(16); + const expectedEom = this._endOfMessage.toString(16); + this._log.error( + `SOM/EOM mismatch: got ${som}/${eom}, expected ${expectedSom}/${expectedEom}` + ); + return null; + } + + + let type, size; + if (this._isLegacy) { + type = buffer[1]; + size = buffer[2]; + } else { + const header = buffer[2] << 8 | buffer[1]; + type = header & 0x1000 ? GalaxyBudsMsgTypes.Request : GalaxyBudsMsgTypes.Response; + size = header & 0x3FF; + } + + const id = buffer[3]; + const payloadSize = Math.max(size - 3, 0); + const expectedLen = 4 + payloadSize + 2 + 1; + if (buffer.length < expectedLen) { + this._log.error(`buffer length too shot: ${buffer.length} < ${expectedLen}`); + return null; + } + + const payload = Array.from(buffer.slice(4, 4 + payloadSize)); + const crcLo = buffer[4 + payloadSize]; + const crcHi = buffer[5 + payloadSize]; + const expectedCrc = crcHi << 8 | crcLo; + const actualCrc = this._checksum([id, ...payload]); + if (actualCrc !== expectedCrc) { + this._log.error('bad CRC'); + return null; + } + + return {id, type, payload}; + } + + _encode(msgId, payload = []) { + const size = 1 + payload.length + 2; + const buf = [this._startOfMessage]; + + if (this._isLegacy) { + buf.push(GalaxyBudsMsgTypes.Request, size); + } else { + const headerLo = size & 0xFF; + const headerHi = size >> 8 & 0xFF; + buf.push(headerLo, headerHi); + } + + buf.push(msgId, ...payload); + const crc = this._checksum([msgId, ...payload]); + buf.push(crc & 0xFF, crc >> 8 & 0xFF); + buf.push(this._endOfMessage); + return Uint8Array.from(buf); + } + + _sendPacket(msgId, payload = []) { + const pkt = this._encode(msgId, payload); + this.sendMessage(pkt); + } + + processData(bytes) { + const resp = this._decode(bytes); + if (!resp) + return; + + const {id, payload} = resp; + + switch (id) { + case GalaxyBudsMsgIds.FW_VERSION: + this._log.info('Parse FirmwareVersion'); + this._parseFirmwareVersion(payload); + break; + + case GalaxyBudsMsgIds.DEBUG_SERIAL: + this._log.info('Parse DebugSerial'); + this._parseDebugSerial(payload); + break; + + case GalaxyBudsMsgIds.CRADLE_SERIAL: + this._log.info('Parse CradleSerial'); + this._parseCradleSerial(payload); + break; + + case GalaxyBudsMsgIds.EXTENDED_STATUS_UPDATED: + this._parseExtendedStatusUpdate(payload); + if (!this._firstExtendedStatusRecieved) { + this._sendManagerInfo(); + this._firstExtendedStatusRecieved = true; + this._getDebugSerial(); + } + break; + + case GalaxyBudsMsgIds.STATUS_UPDATED: + this._log.info('Parse StatusUpdate'); + this._parseStatusUpdate(payload); + break; + + case GalaxyBudsMsgIds.AMBIENT_MODE_UPDATED: + this._log.info('Parse AmbientModeUpdate'); + this._processAmbientMode(payload[0]); + break; + + case LegacyMsgIds.AMBIENT_VOICE_FOCUS: + this._log.info('Parse AmbientVoiceFocus'); + this._processAmbientVoiceFocusDecoder(payload[0]); + break; + + case GalaxyBudsMsgIds.AMBIENT_VOLUME: + this._log.info('Parse AmbientVolume'); + this._processAmbientVolume(payload[0]); + break; + + case GalaxyBudsMsgIds.NOISE_REDUCTION_MODE_UPDATE: + this._log.info('Parse NoiseReductionModeUpdate'); + this._processNCOnOff(payload[0]); + break; + + case GalaxyBudsMsgIds.NOISE_CONTROLS_UPDATE: + this._log.info('Parse NoiseControlUpdate'); + this._processNCModes(payload[0]); + break; + + case GalaxyBudsMsgIds.SET_TOUCHPAD_OPTION: + this._log.info('Parse TouchOptionUpdate'); + this._recvTouchpadOptionL(Boolean(payload[0])); + this._recvTouchpadOptionR(Boolean(payload[1])); + break; + + case GalaxyBudsMsgIds.LOCK_TOUCHPAD: + this._log.info('Parse LockTouchUpdate'); + if (this._features.advancedTouchLock || this._features.advancedTouchIsPinch) + this._processAdvanceTouch(payload); + else + this._processTouchLock(payload); + break; + } + } + + postConnectInitialization() { + this.sendMessage(this._encode(GalaxyBudsMsgIds.EXTENDED_STATUS_UPDATED)); + } + + _parseFirmwareVersion(payload) { + if (!payload || payload.length < 14) + return; + + const info = String.fromCharCode(...payload.slice(2, 14)); + + this._log.info(`Firmware: ${info}`); + this._callbacks?.updateFirmwareInfo?.(info); + } + + _parseDebugSerial(payload) { + if (!payload || payload.length < 22) + return; + + const left = String.fromCharCode(...payload.slice(0, 11)); + const right = String.fromCharCode(...payload.slice(11, 22)); + this._callbacks?.updateLRSerial?.(left, right); + } + + _parseCradleSerial(payload) { + if (!payload || payload.length < 20) + return; + + const serialNumber = String.fromCharCode(...payload.slice(9, 20)); + this._callbacks?.updateCaseSerial?.(serialNumber); + } + + _processBattery(battery) { + const l = battery.left; + const r = battery.right; + const c = battery.case ?? 255; + const mask = battery.mask ?? 0; + + const caseLevel = c > 100 ? 0 : c; + + let leftStatus; + if (l === 0) + leftStatus = 'disconnected'; + else if (mask & 0b00010000) + leftStatus = 'charging'; + else + leftStatus = 'discharging'; + + let rightStatus; + if (r === 0) + rightStatus = 'disconnected'; + else if (mask & 0b00000100) + rightStatus = 'charging'; + else + rightStatus = 'discharging'; + + let caseStatus; + if (caseLevel === 0) + caseStatus = 'disconnected'; + else if (mask & 0b00000001) + caseStatus = 'charging'; + else + caseStatus = 'discharging'; + + const props = { + battery1Level: l, + battery1Status: leftStatus, + battery2Level: r, + battery2Status: rightStatus, + battery3Level: caseLevel, + battery3Status: caseStatus, + }; + + this._callbacks?.updateBatteryProps?.(props); + } + + _processEar(byte) { + let left, right; + + if (this._modelId === GalaxyBudsModel.GalaxyBuds) { + switch (byte) { + case GalaxyBudsLegacyEarDetectionState.Both: + left = GalaxyBudsEarDetectionState.Wearing; + right = GalaxyBudsEarDetectionState.Wearing; + break; + + case GalaxyBudsLegacyEarDetectionState.L: + left = GalaxyBudsEarDetectionState.Wearing; + right = GalaxyBudsEarDetectionState.Idle; + break; + + case GalaxyBudsLegacyEarDetectionState.R: + left = GalaxyBudsEarDetectionState.Idle; + right = GalaxyBudsEarDetectionState.Wearing; + break; + + default: + left = GalaxyBudsEarDetectionState.Idle; + right = GalaxyBudsEarDetectionState.Idle; + break; + } + } else { + left = byte >> 4; + right = byte & 0x0F; + } + + this._callbacks?.updateInEarState?.(left, right); + } + + _processAmbientMode(byte) { + if (!this._features.ambientSound || this._features.noiseControl) + return; + + const enabled = booleanFromByte(byte); + if (enabled === null) + return; + + const mode = enabled ? GalaxyBudsAnc.AmbientSound : GalaxyBudsAnc.Off; + + this._callbacks?.updateNCModes?.(mode); + } + + _processAmbientVoiceFocusDecoder(byte) { + if (!this._features.ambientVoiceFocus) + return; + + const enabled = booleanFromByte(byte); + if (enabled === null) + return; + + this._callbacks?.updateFocusOnVoice?.(enabled); + } + + _processAmbientVolume(byte) { + if (!this._features.ambientSoundVolume) + return; + + this._callbacks?.updateAmbientVolume?.(byte); + } + + _processNCOnOff(byte) { + if (!this._features.noiseCancellation || this._features.noiseControl) + return; + + const enabled = booleanFromByte(byte); + if (enabled === null) + return; + + const mode = enabled ? GalaxyBudsAnc.NoiseReduction : GalaxyBudsAnc.Off; + + this._callbacks?.updateNCModes?.(mode); + } + + _processNCModes(byte) { + if (!this._features.noiseControl) + return; + + if (!isValidByte(byte, GalaxyBudsAnc)) + return; + + this._callbacks?.updateNCModes?.(byte); + } + + _recvNoiseControlCycle(byte) { + const ncCycleProps = {}; + const b = byte; + if (this._features.adaptiveNoiseControl) { + ncCycleProps.ambient = (b & 1 << 0) !== 0; + ncCycleProps.adaptive = (b & 1 << 1) !== 0; + ncCycleProps.off = (b & 1 << 2) !== 0; + ncCycleProps.anc = (b & 1 << 3) !== 0; + ncCycleProps.leftAmbient = (b & 1 << 4) !== 0; + ncCycleProps.leftAdaptive = (b & 1 << 5) !== 0; + ncCycleProps.leftOff = (b & 1 << 6) !== 0; + ncCycleProps.leftAnc = (b & 1 << 7) !== 0; + } else { + ncCycleProps.off = (b & 1 << 0) !== 0; + ncCycleProps.ambient = (b & 1 << 1) !== 0; + ncCycleProps.anc = (b & 1 << 2) !== 0; + + if (this._features.noiseControlModeDualSide) { + ncCycleProps.leftOff = (b & 1 << 4) !== 0; + ncCycleProps.leftAmbient = (b & 1 << 5) !== 0; + ncCycleProps.leftAnc = (b & 1 << 6) !== 0; + } + } + this._callbacks?.updateNoiseControlCycle?.(ncCycleProps); + } + + _recvNoiseReductionLevel(level) { + this._callbacks?.updateNoiseReductionLevel?.(level); + } + + _recvDetectConversations(enable) { + this._callbacks?.updateDetectConversations?.(enable); + } + + _recvDetectConversationsDuration(duration) { + duration = duration > 2 ? 1 : duration; + this._callbacks?.updateDetectConversationsDuration?.(duration); + } + + _processEqPresets(byte, enabled) { + let preset; + if (this._modelId === GalaxyBudsModel.GalaxyBuds) { + if (!enabled) + preset = EqPresets.Off; + else if (byte <= 9) + preset = byte; + else + return; + } else if (!isValidByte(byte, EqPresets)) { + return; + } else { + preset = byte === 0 ? EqPresets.Off : byte - 1; + } + this._callbacks?.updateEqPresets?.(preset); + } + + _recvStereoBal(bal) { + this._callbacks?.updateStereoBal?.(bal); + } + + _recvTouchpadLock(enable) { + this._callbacks?.updateTouchpadLock?.(enable); + } + + _recvAdvanceTouchpadLock(touchProps) { + this._callbacks?.updateAdvanceTouchpadLock?.(touchProps); + } + + _recvTouchpadOptionL(mode) { + this._callbacks?.updateTouchpadOptionL?.(mode); + } + + _recvTouchpadOptionR(mode) { + this._callbacks?.updateTouchpadOptionR?.(mode); + } + + _recvSideToneEnabled(enable) { + this._callbacks?.updateSideToneEnabled?.(enable); + } + + _recvNoiseControlsWithOneEarbud(enable) { + this._callbacks?.updateNoiseControlsWithOneEarbud?.(enable); + } + + _recvOutsideDoubleTap(enable) { + this._callbacks?.updateOutsideDoubleTap?.(enable); + } + + _recvLightingMode(mode) { + this._callbacks?.updateLightingMode?.(mode); + } + + _recvAmbientCustomization(volumeON, volumeLevels, soundtone) { + const customAmbientProps = {}; + customAmbientProps.enable = volumeON === 1; + customAmbientProps.leftVolume = (volumeLevels & 0xF0) >> 4; + customAmbientProps.rightVolume = volumeLevels & 0x0F; + customAmbientProps.soundtone = soundtone; + this._callbacks?.updateAmbientCustomization?.(customAmbientProps); + } + + _processTouchLock(payload) { + const enable = Boolean(payload[0]); + this._recvTouchpadLock(enable); + } + + _processAdvanceTouch(payload) { + const touchProps = {}; + + touchProps.touchpadLock(Boolean(payload[0])); + if (payload.length > 4) { + touchProps.singleTapOn(Boolean(payload[1])); + touchProps.doubleTapOn(Boolean(payload[2])); + touchProps.tripleTapOn(Boolean(payload[3])); + touchProps.touchHoldOn(Boolean(payload[4])); + } + + if (payload.length > 6 && this._features.advancedTouchLockForCalls) { + touchProps.touchHoldOnForCallOff(Boolean(payload[5])); + touchProps.touchHoldOnForCallOff(Boolean(payload[6])); + } + + this._recvAdvanceTouchpadLock(touchProps); + } + + _parseStatusUpdate(payload) { + const battery = {left: null, right: null, case: null, mask: null}; + const inearStatus = payload[5]; + + if (this._modelId === GalaxyBudsModel.GalaxyBuds) { + battery.left = payload[1]; + battery.right = payload[2]; + } else { + battery.left = payload[1]; + battery.right = payload[2]; + battery.case = payload[6]; + + if (this._features.chargingState) + battery.mask = payload[7]; + } + + this._processEar(inearStatus); + } + + _parseExtendedStatusUpdate(payload) { + this._log.info(`Parse ExtendedStatusUpdate Payload length: ${payload.length}`); + const battery = {left: null, right: null, case: null, mask: null}; + const EarlyExit = {}; + const readByte = i => { + if (i >= payload.length) + throw EarlyExit; + return payload[i]; + }; + + this._callbacks?.updateExtendedStatusStarted?.(); + try { + const rev = readByte(0); + + battery.left = readByte(2); + battery.right = readByte(3); + this._processEar(readByte(6)); + + if (this._modelId === GalaxyBudsModel.GalaxyBuds) { + this._processAmbientMode(readByte(7)); + this._processAmbientVoiceFocusDecoder(readByte(8)); + this._processAmbientVolume(readByte(9)); + this._processEqPresets(readByte(11), readByte(10)); + + const hasTouchpadExtra = payload.length > 13; + if (hasTouchpadExtra) { + this._recvTouchpadLock(Boolean(readByte(12))); + const byte13 = readByte(13); + this._recvTouchpadOptionL((byte13 & 0xF0) >> 4); + this._recvTouchpadOptionR(byte13 & 0x0F); + } else { + const byte12 = readByte(12); + this._recvTouchpadLock(Boolean((byte12 & 0xF0) >> 4)); + this._recvTouchpadOptionL(byte12 & 0x0F); + this._recvTouchpadOptionR(byte12 & 0x0F); + } + return; + } // GalaxyBuds + + battery.case = readByte(7); + + if (this._modelId === GalaxyBudsModel.GalaxyBudsPlus) { + this._processAmbientMode(readByte(8)); + this._processAmbientVolume(readByte(9)); + this._processEqPresets(readByte(11)); + this._recvTouchpadLock(Boolean(readByte(12))); + this._recvTouchpadOptionL((readByte(13) & 240) >> 4); + this._recvTouchpadOptionR(readByte(13) & 15); + this._recvOutsideDoubleTap(readByte(14) === 1); + + if (rev >= 8) + this._recvSideToneEnabled(readByte(19) === 1); + + return; + } // GalaxyBudsPlus + + this._processEqPresets(readByte(9)); + + if (this._features.advancedTouchLock) { + const touchProps = {}; + touchProps.touchpadLock = (readByte(10) & 1 << 7) !== 128; + touchProps.touchHoldOn = (readByte(10) & 1 << 0) === 1; + touchProps.tripleTapOn = (readByte(10) & 1 << 1) === 2; + touchProps.doubleTapOn = (readByte(10) & 1 << 2) === 4; + touchProps.singleTapOn = (readByte(10) & 1 << 3) === 8; + + if (this._features.advancedTouchLockForCalls) { + touchProps.touchHoldOnForCallOff = (readByte(10) & 1 << 5) === 32; + touchProps.doubleTapForCallOn = (readByte(10) & 1 << 4) === 16; + } + + if (this._features.AdvancedTouchLockSwipe) + touchProps.swipeOn = (readByte(10) & 1 << 6) === 64; + + + this._recvAdvanceTouchpadLock(touchProps); + } else { + this._recvTouchpadLock(readByte(10) === 1); + } + + this._recvTouchpadOptionL((readByte(11) & 0xF0) >> 4); + this._recvTouchpadOptionR(readByte(11) & 0x0F); + + if (this._modelId === GalaxyBudsModel.GalaxyBudsLive) { + this._processNCOnOff(readByte(12)); + if (rev >= 7) + this._recvStereoBal(readByte(22)); + + return; + } + + this._processNCModes(readByte(12)); + + this._recvNoiseControlCycle(readByte(21)); + + this._processAmbientVolume(readByte(23)); + this._recvNoiseReductionLevel(readByte(24)); + + if (this._modelId !== GalaxyBudsModel.GalaxyBuds2) { + this._recvDetectConversations(readByte(26) === 1); + this._recvDetectConversationsDuration(readByte(27)); + } + + if (this._modelId === GalaxyBudsModel.GalaxyBudsPro) { + if (rev >= 5) + this._recvStereoBal(readByte(29)); + + if (rev >= 7) + this._recvOutsideDoubleTap(readByte(31) === 1); + + if (rev >= 8) { + this._recvNoiseControlsWithOneEarbud(readByte(32) === 1); + this._recvAmbientCustomization(readByte(33), readByte(34), readByte(35)); + } + + if (rev >= 9) + this._recvSideToneEnabled(readByte(36) === 1); + } else if (this._modelId === GalaxyBudsModel.GalaxyBuds2) { + this._recvStereoBal(readByte(25)); + + if (rev >= 3) + this._recvNoiseControlsWithOneEarbud(readByte(28) === 1); + + if (rev >= 5) { + this._recvAmbientCustomization(readByte(29), readByte(30), readByte(31)); + this._recvOutsideDoubleTap(readByte(32) === 1); + } + + if (rev >= 6) + this._recvSideToneEnabled(readByte(33) === 1); + + if (rev >= 10) + battery.mask = readByte(36); + } else if (this._modelId === GalaxyBudsModel.GalaxyBuds2Pro) { + this._recvStereoBal(readByte(25)); + this._recvNoiseControlsWithOneEarbud(readByte(28) === 1); + this._recvAmbientCustomization(readByte(29), readByte(30), readByte(31)); + this._recvOutsideDoubleTap(readByte(32) === 1); + this._recvSideToneEnabled(readByte(33) === 1); + + if (rev >= 11) + battery.mask = readByte(43); + } else { + this._recvStereoBal(readByte(25)); + this._recvNoiseControlsWithOneEarbud(readByte(28) === 1); + this._recvAmbientCustomization(readByte(29), readByte(30), readByte(31)); + this._recvOutsideDoubleTap(readByte(32) === 1); + this._recvSideToneEnabled(readByte(33) === 1); + + if (this._modelId === GalaxyBudsModel.GalaxyBudsFe) + battery.mask = readByte(43); + else + battery.mask = readByte(42); + + if (this._modelId === GalaxyBudsModel.GalaxyBuds3Pro) + this._recvLightingMode(readByte(50)); + } + } catch (e) { + if (e !== EarlyExit) + throw e; + } finally { + this._processBattery(battery); + this._callbacks?.updateExtendedStatusEnded?.(); + } + }// _parseExtendedStatusUpdate + + _sendManagerInfo() { + this._log.info('Set ManagerInfo'); + const payload = [1, ClientSamsungDevice, AndroidSdkVersion]; + this._sendPacket(GalaxyBudsMsgIds.MANAGER_INFO, payload); + } + + _getDebugSerial() { + this._log.info('Get DebugSerial'); + this._sendPacket(GalaxyBudsMsgIds.DEBUG_SERIAL); + } + + setAmbientSoundOnOff(mode) { + this._log.info('Set AmbientSoundOnOff'); + const payload = [mode === GalaxyBudsAnc.AmbientSound ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.SET_AMBIENT_MODE, payload); + } + + setFocusOnVoice(enabled) { + this._log.info('Set FocusOnVoice'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(LegacyMsgIds.AMBIENT_VOICE_FOCUS, payload); + } + + setAmbientVolume(level) { + this._log.info('Set AmbientVolume'); + const payload = [level]; + this._sendPacket(GalaxyBudsMsgIds.AMBIENT_VOLUME, payload); + } + + setNCOnOff(mode) { + this._log.info('Set NCOnOff'); + const payload = [mode === GalaxyBudsAnc.NoiseReduction ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.SET_NOISE_REDUCTION, payload); + } + + setNCModes(mode) { + this._log.info('Set NCModes'); + const payload = [mode]; + this._sendPacket(GalaxyBudsMsgIds.NOISE_CONTROLS, payload); + } + + setNoiseCancellationLevel(level) { + this._log.info('Set NoiseCancellationLevel'); + const payload = [level]; + this._sendPacket(GalaxyBudsMsgIds.NOISE_REDUCTION_LEVEL, payload); + } + + setEqPresets(preset) { + this._log.info('Set EqPresets'); + let payload; + const enabled = preset !== EqPresets.Off; + + if (this._modelId === GalaxyBudsModel.GalaxyBuds) + payload = [enabled ? 1 : 0, preset]; + else + payload = [!enabled ? 0 : preset + 1]; + this._sendPacket(GalaxyBudsMsgIds.EQUALIZER, payload); + } + + setStereoBalance(bal) { + this._log.info('Set StereoBalance'); + const payload = [bal]; + this._sendPacket(GalaxyBudsMsgIds.SET_HEARING_ENHANCEMENTS, payload); + } + + setTouchPadLock(enabled) { + this._log.info('Set TouchPadLock'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.LOCK_TOUCHPAD, payload); + } + + setTouchPadAdvance(touchProps) { + this._log.info('Set TouchPadAdvance'); + const payload = []; + payload.push(touchProps.touchpadLock ? 1 : 0); + payload.push(touchProps.singleTapOn ? 1 : 0); + payload.push(touchProps.doubleTapOn ? 1 : 0); + payload.push(touchProps.tripleTapOn ? 1 : 0); + payload.push(touchProps.touchHoldOn ? 1 : 0); + + if (this._features.advancedTouchLockForCalls) { + payload.push(touchProps.doubleTapForCallOn ? 1 : 0); + payload.push(touchProps.touchHoldOnForCallOff ? 1 : 0); + } + if (this._features.advancedTouchIsPinch) + payload.push(touchProps.singleTapOn ? 1 : 0); // Swipe byte + + if (this._features.lightingControl) + payload.push(touchProps.lightingMode); + + if (this._features.quickLaunchAdvance) + payload.push(0x00); + + this._sendPacket(GalaxyBudsMsgIds.LOCK_TOUCHPAD, payload); + } + + setTouchAndHoldLRModes(touchAndHoldProps) { + this._log.info('Set TouchAndHoldLRModes'); + const payload = [touchAndHoldProps.left, touchAndHoldProps.right]; + this._sendPacket(GalaxyBudsMsgIds.SET_TOUCHPAD_OPTION, payload); + } + + setNcCycleLegacy(props) { + this._log.info('Set NcCycleLegacy'); + const payload = []; + + const encodeSide = val => { + const off = val >> 0 & 1; + const ambient = val >> 1 & 1; + const anc = val >> 2 & 1; + return [off, ambient, anc]; + }; + + if (this._features.noiseControlModeDualSide) { + payload.push(...encodeSide(props.left)); + payload.push(...encodeSide(props.right)); + } else { + payload.push(...encodeSide(props.right)); + } + + this._sendPacket(GalaxyBudsMsgIds.SET_TOUCH_AND_HOLD_NOISE_CONTROLS, payload); + } + + setNcCycle(props) { + this._log.info('Set NcCycle'); + const payload = []; + + const encodeByte = val => { + let b = 0x00; + + if (val >> 2 & 1) + b |= 0x08; + + if (val >> 0 & 1) + b |= 0x04; + + if (this._features.adaptiveNoiseControl) { + if (val >> 3 & 1) + b |= 0x02; + } + + if (val >> 1 & 1) + b |= 0x01; + + if (b === 0x00) + b = 0x09; + + return b; + }; + + const leftVal = this._features.noiseControlModeDualSide ? props.left : props.right; + + payload.push(encodeByte(leftVal)); + payload.push(encodeByte(props.right)); + + this._sendPacket(GalaxyBudsMsgIds.SET_TOUCH_AND_HOLD_NOISE_CONTROLS, payload); + } + + setDetectConversations(enabled) { + this._log.info('Set DetectConversations'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.SET_DETECT_CONVERSATIONS, payload); + } + + setDetectConversationsDuration(duration) { + this._log.info('Set DetectConversationsDuration'); + const payload = [duration]; + this._sendPacket(GalaxyBudsMsgIds.SET_DETECT_CONVERSATIONS_DURATION, payload); + } + + setSideTone(enabled) { + this._log.info('Set SideTone'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.SET_SIDETONE, payload); + } + + setNoiseControlsWithOneEarbud(enabled) { + this._log.info('Set NoiseControlsWithOneEarbud'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.SET_ANC_WITH_ONE_EARBUD, payload); + } + + setOutsideDoubleTap(enabled) { + this._log.info('Set OutsideDoubleTap'); + const payload = [enabled ? 1 : 0]; + this._sendPacket(GalaxyBudsMsgIds.OUTSIDE_DOUBLE_TAP, payload); + } + + setCustomizeAmbientSound(props) { + this._log.info('Set CustomizeAmbientSound'); + const payload = [props.enable ? 1 : 0]; + payload.push(props.leftVolume); + payload.push(props.rightVolume); + payload.push(props.soundtone); + this._sendPacket(GalaxyBudsMsgIds.CUSTOMIZE_AMBIENT_SOUND, payload); + } + + setRingMyBuds(state) { + this._log.info('Set RingMyBuds'); + + if (state === 'playing') { + if (this._features.fmgRingWhileWearing) + this._sendPacket(GalaxyBudsMsgIds.FIND_MY_EARBUDS_ON_WEARING_START); + else + this._sendPacket(GalaxyBudsMsgIds.FIND_MY_EARBUDS_START); + } else { + this._sendPacket(GalaxyBudsMsgIds.FIND_MY_EARBUDS_STOP); + } + } + + destroy() { + super.destroy(); + } +}); + + diff --git a/bbm/lib/devices/gattBas/gattBasConfig.js b/bbm/lib/devices/gattBas/gattBasConfig.js new file mode 100644 index 0000000000..33829a6fe0 --- /dev/null +++ b/bbm/lib/devices/gattBas/gattBasConfig.js @@ -0,0 +1,44 @@ +'use strict'; + +export const Bat1Group = new Set([ + 'Front', 'Top', 'Upper', 'Main', 'Inside', 'Left', 'Internal', +]); + +export const Bat2Group = new Set([ + 'Back', 'Bottom', 'Lower', 'Backup', 'Auxiliary', + 'Supplementary', 'Outside', 'Right', 'External', +]); + +export const CpfNames = { + 0x0100: 'Front', 0x0101: 'Back', 0x0102: 'Top', 0x0103: 'Bottom', + 0x0104: 'Upper', 0x0105: 'Lower', 0x0106: 'Main', 0x0107: 'Backup', + 0x0108: 'Auxiliary', 0x0109: 'Supplementary', 0x010B: 'Inside', 0x010C: 'Outside', + 0x010D: 'Left', 0x010E: 'Right', 0x010F: 'Internal', 0x0110: 'External', +}; + +export const supportedCommonIcons = [ + 'audio-card', 'audio-speakers', 'audio-speakers2', 'audio-speakers3', + 'audio-headphones', 'audio-headset', 'headphone1', 'earbuds', + 'earbuds-wingtip', 'earbuds-wingtip2', 'earbuds-neckband', 'earbuds-stem', + 'earbuds-stem2', 'earbuds-stem3', 'input-microphone', 'input-gaming', + 'input-gaming2', 'input-gaming3', 'input-keyboard', 'input-keyboard2', 'input-mouse', + 'input-tablet', 'touchpad', 'phone', 'camera-photo', 'camera-video', + 'computer', 'video-display', 'printer', 'scanner', 'multimedia-player', + 'modem', 'network-wireless', 'wearable', 'wearable2', +]; + +export const supportedCircularWidgetIcons = [ + 'audio-card', 'audio-speakers', 'audio-speakers2', 'audio-speakers3', + 'audio-headphones', 'audio-headset', 'headphone1', 'earbuds', + 'earbuds-left', 'earbuds-right', 'earbuds-wingtip', 'earbuds-wingtip-left', + 'earbuds-wingtip-right', 'earbuds-wingtip2', 'earbuds-wingtip2-left', + 'earbuds-wingtip2-right', 'earbuds-neckband', 'earbuds-stem', 'earbuds-stem-left', + 'earbuds-stem-right', 'earbuds-stem2', 'earbuds-stem2-left', 'earbuds-stem2-right', + 'earbuds-stem3', 'earbuds-stem3-left', 'earbuds-stem3-right', 'case-normal', + 'case-narrow', 'case-oval', 'case-oval-short', 'input-microphone', 'input-gaming', + 'input-gaming2', 'input-gaming3', 'input-keyboard', 'input-keyboard2', + 'input-split-keyboard-l', 'input-split-keyboard-r', 'input-mouse', + 'input-tablet', 'touchpad', 'phone', 'multimedia-player', 'pda', 'camera-photo', + 'camera-video', 'computer', 'video-display', 'printer', 'scanner', + 'modem', 'network-wireless', 'wearable', 'wearable2', +]; diff --git a/bbm/lib/devices/gattBas/gattBasDbus.js b/bbm/lib/devices/gattBas/gattBasDbus.js new file mode 100644 index 0000000000..e9856d04bc --- /dev/null +++ b/bbm/lib/devices/gattBas/gattBasDbus.js @@ -0,0 +1,246 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {Bat1Group, Bat2Group, CpfNames} from './gattBasConfig.js'; + +/** +Credits: https://github.com/Genteure +https://github.com/maniacx/Bluetooth-Battery-Meter/issues/21#issuecomment-2267584302 +**/ + +const BLUEZ_BUS_NAME = 'org.bluez'; +const BLUEZ_ROOT_PATH = '/'; +const BLUEZ_GATT_CHARACTERISTIC = 'org.bluez.GattCharacteristic1'; +const BLUEZ_GATT_DESCRIPTOR = 'org.bluez.GattDescriptor1'; +const UUID_CHAR_BATTERY_LEVEL = '00002a19-0000-1000-8000-00805f9b34fb'; +const UUID_CHAR_PRESENTATION_FORMAT = '00002904-0000-1000-8000-00805f9b34fb'; + +export class GattBasDbus { + constructor(devicePath, callbacks) { + const identifier = getDeviceIdentifier(devicePath); + const tag = `GattBasDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('GattBasDevice init'); + this._devicePath = devicePath; + this._callbacks = callbacks; + this._charInfo = {}; + this._battProps = {}; + + this._initBatteryCharacteristics(); + } + + async _initBatteryCharacteristics() { + this._log.info(`Scanning for battery characteristics on${this._devicePath}`); + + try { + let objectManager; + try { + objectManager = await Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, + Gio.DBusProxyFlags.NONE, + null, + BLUEZ_BUS_NAME, + BLUEZ_ROOT_PATH, + 'org.freedesktop.DBus.ObjectManager', + null + ); + } catch (e) { + this._log.error(e, 'Failed to create Proxy objectManager'); + return; + } + let managed; + try { + const rawManaged = await objectManager.call( + 'GetManagedObjects', + null, + Gio.DBusCallFlags.NONE, + -1, + null + ); + managed = rawManaged.get_child_value(0).deepUnpack(); + } catch (e) { + this._log.error(e, 'Failed during call GetManagedObjects'); + return; + } + + + for (const [path, ifaces] of Object.entries(managed)) { + if (!path.startsWith(this._devicePath)) + continue; + + const charProps = ifaces[BLUEZ_GATT_CHARACTERISTIC]; + if (!charProps || charProps['UUID'].deepUnpack() !== UUID_CHAR_BATTERY_LEVEL) + continue; + + let cpfName = null; + try { + for (const [dpath, descIfaces] of Object.entries(managed)) { + if (!dpath.startsWith(path)) + continue; + + const descProps = descIfaces[BLUEZ_GATT_DESCRIPTOR]; + if (!descProps || + descProps['UUID'].deepUnpack() !== UUID_CHAR_PRESENTATION_FORMAT) + continue; + + // eslint-disable-next-line no-await-in-loop + const descProxy = await Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null, + BLUEZ_BUS_NAME, dpath, BLUEZ_GATT_DESCRIPTOR, null + ); + + // eslint-disable-next-line no-await-in-loop + const descResult = await descProxy.call( + 'ReadValue', GLib.Variant.new('(a{sv})', [{}]), + Gio.DBusCallFlags.NONE, -1, null + ); + + const arr = descResult.get_child_value(0).deepUnpack(); + if (arr.length >= 7) { + cpfName = CpfNames[arr[5] | arr[6] << 8] || 'unknown'; + this._log.info(`cpfName = ${cpfName}`); + } + break; + } + } catch (e) { + this._log.error(e, `Failed to read CPF descriptor for ${path}`); + } + + try { + // eslint-disable-next-line no-await-in-loop + const proxy = await Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null, + BLUEZ_BUS_NAME, path, BLUEZ_GATT_CHARACTERISTIC, null + ); + + const sigId = + proxy.connect('g-properties-changed', this._onPropertiesChanged.bind(this)); + + this._charInfo[path] = {proxy, sigId, cpf: cpfName}; + + this._log.info(`Starting notifications for${path}`); + proxy.call( + 'StartNotify', null, Gio.DBusCallFlags.NONE, + -1, null, + this._makeStartNotifyCb(path) + ); + } catch (e) { + this._log.error(e, `Failed to initialize characteristic at ${path}`); + } + } + } catch (e) { + this._log.error(e, 'Failed to initialize battery characteristics'); + } + } + + _makeStartNotifyCb(path) { + return (proxy, result) => { + try { + proxy.call_finish(result); + } catch (e) { + this._log.error(e, `StartNotify failed for ${path}`); + return; + } + + this._log.info(`StartNotify successful for ${path} reading initial value`); + proxy.call( + 'ReadValue', GLib.Variant.new('(a{sv})', [{}]), + Gio.DBusCallFlags.NONE, -1, null, + this._makeReadCb(path) + ); + }; + } + + _makeReadCb(path) { + return (proxy, result) => { + try { + const res = proxy.call_finish(result); + const byteArray = res.get_child_value(0).deepUnpack(); + const level = byteArray.length > 0 ? byteArray[0] : null; + this._updateLevel(path, level); + } catch (e) { + this._log.error(e, `ReadValue failed for ${path}`); + } + }; + } + + _onPropertiesChanged(proxy, changed, _invalidated) { + try { + const props = changed.deepUnpack(); + const value = props['Value']; + if (!value) + return; + + const arr = value.deepUnpack(); + const level = arr.length > 0 ? arr[0] : null; + const path = proxy.get_object_path(); + this._log.info(`Battery level changed for${path} : ${level}`); + this._updateLevel(path, level); + } catch (e) { + this._log.error(e, 'Error onPropertiesChanged'); + } + } + + _updateLevel(path, level) { + this._battProps[path] = level; + + const entries = Object.entries(this._charInfo).map(([p, info]) => ({ + path: p, + cpf: info.cpf ?? 'unknown', + level: this._battProps[p], + index: Object.keys(this._charInfo).indexOf(p), + })); + + let battery1 = null; + let battery2 = null; + const battery3 = []; + + battery1 = entries.find(entry => Bat1Group.has(entry.cpf)); + battery2 = entries.find(entry => Bat2Group.has(entry.cpf)); + + for (const entry of entries) { + if (battery1 && battery1.path === entry.path || + battery2 && battery2.path === entry.path) + continue; + battery3.push(entry); + } + + if (!battery1 && battery3.length > 0) + battery1 = battery3.shift(); + + if (!battery2 && battery3.length > 0) + battery2 = battery3.shift(); + + const props = {}; + if (battery1) + props.battery1Level = battery1.level; + if (battery2) + props.battery2Level = battery2.level; + if (battery3.length > 0) + props.battery3Level = battery3[0].level; + + for (const [key, val] of Object.entries(props)) { + const slot = parseInt(key.match(/\d+/)[0]) - 1; + const batteryPath = [battery1, battery2, ...battery3][slot]?.path; + const cpf = this._charInfo[batteryPath]?.cpf ?? 'Unknown'; + this._log.info(`${cpf} Battery level: ${val}%`); + } + + if (this._callbacks.updateBatteryProps) + this._callbacks.updateBatteryProps(props); + } + + destroy() { + this._log.info(`Destroying GattBasDevice for${this._devicePath}`); + for (const info of Object.values(this._charInfo)) { + if (info.sigId) + info.proxy.disconnect(info.sigId); + } + this._charInfo = {}; + this._battProps = {}; + this._callbacks = {}; + } +} + diff --git a/bbm/lib/devices/gattBas/gattBasDevices.js b/bbm/lib/devices/gattBas/gattBasDevices.js new file mode 100644 index 0000000000..fd64d931ad --- /dev/null +++ b/bbm/lib/devices/gattBas/gattBasDevices.js @@ -0,0 +1,189 @@ +'use strict'; +import GObject from 'gi://GObject'; + +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {supportedCommonIcons} from './gattBasConfig.js'; +import {GattBasDbus} from './gattBasDbus.js'; + +const GattBasUUID = '0000180f-0000-1000-8000-00805f9b34fb'; + +export const DeviceTypeGattBas = 'gatt-bas'; + +export function isGattBas(bluezDeviceProxy, uuids) { + const bluezProps = []; + const supported = uuids.includes(GattBasUUID) ? 'yes' : 'no'; + return {supported, bluezProps}; +} + + +export const GattBasDevices = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GattBasDevices', +}, class GattBasDevices extends GObject.Object { + _init(settings, devicePath, icon, updateDeviceMapCb) { + super._init(); + this._settings = settings; + this._devicePath = devicePath; + this._icon = icon; + this._config = createConfig(); + this._props = createProperties(); + this.updateDeviceMapCb = updateDeviceMapCb; + this._battInfoRecieved = false; + this._callbacks = { + updateBatteryProps: this.updateBatteryProps.bind(this), + }; + + this._bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const servicesResolved = this._bluezDeviceProxy.ServicesResolved; + if (!servicesResolved) { + this._bluezSignalId = this._bluezDeviceProxy.connect( + 'g-properties-changed', () => this._onBluezPropertiesChanged()); + } else { + this._startGattBasDbus(); + } + } + + _onBluezPropertiesChanged() { + const modalias = this._bluezDeviceProxy.Modalias; + if (modalias) { + this._startGattBasDbus(); + if (this._bluezDeviceProxy && this._bluezSignalId) + this._bluezDeviceProxy.disconnect(this._bluezSignalId); + this._bluezSignalId = null; + this._bluezDeviceProxy = null; + } + } + + _startGattBasDbus() { + this._gattBasDbus = new GattBasDbus(this._devicePath, this._callbacks); + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + this._props = {...this._props, ...battInfo}; + this._config.battery1ShowOnDisconnect = true; + this._config.panelButtonLabelFixed = false; + + const parsedDevices = this._settings.get_strv('gattbas-list').map(JSON.parse); + + if (!parsedDevices || parsedDevices.length === 0 || + !parsedDevices.some(device => device.path === this._devicePath)) { + const icons = this._validIcons(this._icon); + + this._config.commonIcon = icons.supportedicon; + this._config.albumArtIcon = icons.albumArtIcon; + this._config.battery1Icon = icons.supportedicon; + this._config.battery2Icon = icons.supportedicon; + this._config.battery3Icon = icons.supportedicon; + this._addConfigToSettings(); + } else { + this._loadConfigFromSettings(parsedDevices); + } + + this._settings.connectObject( + 'changed::gattbas-list', () => { + const parsedBasDevices = this._settings.get_strv('gattbas-list').map(JSON.parse); + this._loadConfigFromSettings(parsedBasDevices); + }, + this + ); + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + } + + _addConfigToSettings() { + const item = { + 'path': this._devicePath, + 'alias': '', + 'icon': this._config.commonIcon, + 'icon-batt1': this._config.battery1Icon, + 'icon-batt2': this._config.battery2Icon, + 'icon-batt3': this._config.battery3Icon, + }; + this._settings.set_strv('gattbas-list', [JSON.stringify(item)]); + } + + _loadConfigFromSettings(parsedDevices) { + const existingItem = parsedDevices.find(device => device.path === this._devicePath); + if (!existingItem) + return; + this._config.commonIcon = existingItem['icon']; + const albumArtIcon = this._validAlbumArtIcons(existingItem['icon']); + this._config.albumArtIcon = albumArtIcon; + this._config.battery1Icon = existingItem['icon-batt1']; + this._config.battery2Icon = existingItem['icon-batt2']; + this._config.battery3Icon = existingItem['icon-batt3']; + this.dataHandler?.setConfig(this._config); + } + + _validAlbumArtIcons(icon) { + const albumArtIconMap = { + 'audio-speakers3': 'audio-speakers3', + 'headphone1': 'audio-headphones', + 'input-gaming3': 'input-gaming', + 'input-gaming2': 'input-gaming', + 'input-keyboard2': 'input-keyboard', + }; + return albumArtIconMap[icon] || icon; + } + + _validIcons(icon) { + if (supportedCommonIcons.includes(icon)) { + const iconMap = { + 'phone-apple-iphone-symbolic': 'phone', + 'phone-google-nexus-one': 'phone', + 'phone-samsung-galaxy-s': 'phone', + }; + const supportedicon = iconMap[icon] || icon; + const albumArtIcon = this._validAlbumArtIcons(supportedicon); + return {supportedicon, albumArtIcon}; + } else { + return {supportedicon: 'audio-headphones', albumArtIcon: 'audio-headphones'}; + } + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + const b1 = props.battery1Level; + const b2 = props.battery2Level; + + let computed; + + if ((b1 === undefined || b1 === 0) && (b2 === undefined || b2 === 0)) + computed = undefined; + else if (b1 === undefined || b1 === 0) + computed = b2; + else if (b2 === undefined || b2 === 0) + computed = b1; + else + computed = Math.min(b1, b2); + + + this._props.computedBatteryLevel = computed; + + if (!this._battInfoRecieved) + this._startConfiguration(props); + + this.dataHandler?.setProps(this._props); + } + + + destroy() { + if (this._bluezDeviceProxy && this._bluezSignalId) + this._bluezDeviceProxy.disconnect(this._bluezSignalId); + this._bluezSignalId = null; + this._bluezDeviceProxy = null; + this.dataHandler = null; + this._battInfoRecieved = false; + } +}); diff --git a/bbm/lib/devices/gfps/gfpsConfig.js b/bbm/lib/devices/gfps/gfpsConfig.js new file mode 100644 index 0000000000..ae860bbaf9 --- /dev/null +++ b/bbm/lib/devices/gfps/gfpsConfig.js @@ -0,0 +1,32 @@ +'use strict'; + +// Google Fast Pair Service (GFPS) RFCOMM Message Stream UUID +export const GfpsUUID = 'df21fe2c-2515-4fdb-8886-f12c4d67927c'; +export const DeviceTypeGfps = 'gfps'; + +// Message Group codes +export const MessageGroup = { + DEVICE_INFO: 0x03, + HEARABLE_CONTROL: 0x08, +}; + +// Message Code codes +export const DeviceInfoMessage = { + BATTERY_UPDATE: 0x03, + FIRMWARE_REV: 0x09, +}; + +export const HearableControlMessage = { + GET_ANC_STATE: 0x11, + SET_ANC_STATE: 0x12, + NOTIFY_ANC_STATE: 0x13, +}; + +// Active Noise Control (ANC) Modes bitmasks / flags +export const ANCMode = { + TRANSPARENCY: 0x80, // Bit 7 + ADAPTIVE: 0x40, // Bit 6 + OFF: 0x20, // Bit 5 + ANC_ON: 0x08, // Bit 3 +}; + diff --git a/bbm/lib/devices/gfps/gfpsDevice.js b/bbm/lib/devices/gfps/gfpsDevice.js new file mode 100644 index 0000000000..4fc4c8958f --- /dev/null +++ b/bbm/lib/devices/gfps/gfpsDevice.js @@ -0,0 +1,302 @@ +'use strict'; + +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {buds2to1BatteryLevel, validateProperties, launchConfigureWindow} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {GfpsSocket} from './gfpsSocket.js'; +import {DeviceTypeGfps, GfpsUUID, ANCMode} from './gfpsConfig.js'; + +export {DeviceTypeGfps}; + +export function isGfps(bluezDeviceProxy, uuids) { + const bluezProps = []; + // Bypass true Google Pixel Buds so that GoogleBudsDevice (libmaestro) handles them + if (uuids.includes('25e97ff7-24ce-4c4c-8951-f764a708f7b5')) + return {supported: 'no', bluezProps}; + const supported = uuids.includes(GfpsUUID) ? 'yes' : 'no'; + return {supported, bluezProps}; +} + +export const GfpsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GfpsDevice', +}, class GfpsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GfpsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- GfpsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._settableToggles = null; + + this._config = createConfig(); + this._props = createProperties(); + + this._callbacks = { + updateInitDevice: this.updateInitDevice.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateNoiseControl: this.updateNoiseControl.bind(this), + }; + + const profile = {type: DeviceTypeGfps, uuid: GfpsUUID}; + + this._gfpsSocket = new GfpsSocket( + this._devicePath, + profileManager, + profile, + this._callbacks + ); + } + + updateInitDevice(battType) { + this._hasMultipleBatteries = battType !== 1; + this._commonIcon = this._hasMultipleBatteries ? 'earbuds' : 'headphone1'; + this._config.battery1ShowOnDisconnect = true; + this._config.battery2ShowOnDisconnect = this._hasMultipleBatteries; + this._config.showSettingsButton = true; + if (this._hasMultipleBatteries) + this._caseIcon = 'case-normal'; + + this._createDefaultSettings(); + + const devicesList = this._settings.get_strv('gfps-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'gfps-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorGfpsListGsettings(true); + this._updateIcons(); + this._addToggle1Button(); + this._startConfiguration(); + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + alias: this._alias, + icon: this._commonIcon, + 'multiple-batt': this._hasMultipleBatteries, + + ...this._hasMultipleBatteries && { + 'case': this._caseIcon, + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('gfps-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('gfps-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + this._commonIcon = this._settingsItems['icon']; + this._caseIcon = this._settingsItems['case']; + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('gfps-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._hasMultipleBatteries) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + } + + _monitorGfpsListGsettings(monitor) { + if (monitor) { + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + + this._settingsHandlerId = this._settings?.connect('changed::gfps-list', () => + this._updateGsettingsProps()); + } else { + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + } + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + if (this._hasMultipleBatteries) { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery3Icon = this._caseIcon; + } else { + this._config.battery1Icon = this._commonIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + _addToggle1Button() { + this._config.toggle1Title = _('Noise Control'); + this._config.toggle1Button1Icon = 'bbm-anc-off-symbolic'; + this._config.toggle1Button1Name = _('Off'); + this._config.toggle1Button2Icon = 'bbm-anc-on-symbolic'; + this._config.toggle1Button2Name = _('Noise Cancellation'); + this._props.toggle1Visible = false; + } + + _updateAncConfig(settableToggles) { + const modes = [ + { + key: 'off', + mode: ANCMode.OFF, + icon: 'bbm-anc-off-symbolic', + label: _('Off'), + }, + { + key: 'transparency', + mode: ANCMode.TRANSPARENCY, + icon: 'bbm-transperancy-symbolic', + label: _('Transparency'), + }, + { + key: 'adaptive', + mode: ANCMode.ADAPTIVE, + icon: 'bbm-adaptive-symbolic', + label: _('Adaptive'), + }, + { + key: 'anc', + mode: ANCMode.ANC_ON, + icon: 'bbm-anc-on-symbolic', + label: _('Noise Cancellation'), + }, + ]; + + this._toggle1ButtonToAncMode = []; + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Icon`] = ''; + this._config[`toggle1Button${i}Name`] = ''; + this._toggle1ButtonToAncMode[i] = null; + } + + let index = 1; + + for (const entry of modes) { + if (!(settableToggles & entry.mode)) + continue; + + this._config[`toggle1Button${index}Icon`] = entry.icon; + this._config[`toggle1Button${index}Name`] = entry.label; + + this._toggle1ButtonToAncMode[index] = entry.mode; + + index++; + } + + this._props.toggle1Visible = index > 2; + } + + _startConfiguration() { + this.dataHandler = new DataHandler(this._config, this._props); + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + this.dataHandler?.setProps(this._props); + } + + updateNoiseControl(mode, settableToggles) { + if (this._settableToggles !== settableToggles) { + this._settableToggles = settableToggles; + this._updateAncConfig(settableToggles); + this.dataHandler?.setConfig(this._config); + this.dataHandler?.setProps(this._props); + } + + if (mode !== null) + this._ancMode = mode; + + let toggleIndex = + this._toggle1ButtonToAncMode?.findIndex(v => v === this._ancMode); + + if (toggleIndex < 0) + toggleIndex = 0; + + this._props.toggle1State = toggleIndex; + + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + const ancMode = this._toggle1ButtonToAncMode?.[index]; + + if (ancMode == null) + return; + + this._props.toggle1State = index; + this.dataHandler?.setProps(this._props); + + this._gfpsSocket?.sendAncState(ancMode); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, DeviceTypeGfps, this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._gfpsSocket?.destroy(); + this._gfpsSocket = null; + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + this._monitorGfpsListGsettings(false); + this._settings = null; + } +}); diff --git a/bbm/lib/devices/gfps/gfpsSocket.js b/bbm/lib/devices/gfps/gfpsSocket.js new file mode 100644 index 0000000000..decd57a9d2 --- /dev/null +++ b/bbm/lib/devices/gfps/gfpsSocket.js @@ -0,0 +1,189 @@ +'use strict'; + +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {MessageGroup, DeviceInfoMessage, HearableControlMessage} from './gfpsConfig.js'; + +export const GfpsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GfpsSocket', +}, class GfpsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GfpsSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('GfpsSocket init'); + + this._devicePath = devicePath; + this._callbacks = callbacks; + this._initialized = false; + this._halfPacket = new Uint8Array(0); + + this._lastVersion = 0x01; // GFPS ANC Protocol version (1 or 2) + this._lastUiToggles = 0xE8; // Default to all supported: Transparency, Adaptive, Off, ANC + this._lastSettableToggles = 0xE8; + + this.startSocket(); + } + + _isValidPacketStream(bytes) { + let offset = 0; + while (offset < bytes.length) { + if (bytes.length - offset < 4) + return false; + + const len = bytes[offset + 2] << 8 | bytes[offset + 3]; + const packetLength = 4 + len; + if (bytes.length - offset < packetLength) + return false; + + offset += packetLength; + } + + return offset === bytes.length; + } + + processData(bytes) { + let packetToProcess = bytes; + let isValid = this._isValidPacketStream(bytes); + if (!isValid && this._halfPacket.length > 0) { + const merged = new Uint8Array(this._halfPacket.length + bytes.length); + merged.set(this._halfPacket, 0); + merged.set(bytes, this._halfPacket.length); + if (this._isValidPacketStream(merged)) { + packetToProcess = merged; + isValid = true; + } + } + + if (isValid) { + let offset = 0; + while (offset < packetToProcess.length) { + const group = packetToProcess[offset]; + const code = packetToProcess[offset + 1]; + const len = packetToProcess[offset + 2] << 8 | packetToProcess[offset + 3]; + const payload = packetToProcess.slice(offset + 4, offset + 4 + len); + this._parseData(group, code, payload); + offset += 4 + len; + } + + this._halfPacket = new Uint8Array(0); + } else if (bytes.length <= 100) { + this._halfPacket = bytes; + } else { + this._halfPacket = new Uint8Array(0); + } + } + + postConnectInitialization() { + this._log.info('GFPS Socket Initialized'); + } + + _parseData(group, code, payload) { + if (group === MessageGroup.DEVICE_INFO && code === DeviceInfoMessage.BATTERY_UPDATE) { + const parseComponent = byte => { + if ((byte & 0x7F) === 0x7F) + return {level: 0, status: 'disconnected'}; + + const charging = (byte & 0x80) !== 0; + const level = byte & 0x7F; + return { + level, + status: charging ? 'charging' : 'discharging', + }; + }; + + const bat1 = parseComponent(payload[0]); + let bat2, bat3; + if (payload.length === 3) { + bat2 = parseComponent(payload[1]); + bat3 = parseComponent(payload[2]); + } else if (payload.length === 1) { + bat2 = {level: 0, status: 'disconnected'}; + bat3 = {level: 0, status: 'disconnected'}; + } else { + this._log.info(`Unexpected GFPS battery payload length: ${payload.length}`); + return; + } + + this._log.info( + `Parsed GFPS Battery: L=${bat1.level}% (${bat1.status}), ` + + `R=${bat2.level}% (${bat2.status}), C=${bat3.level}% (${bat3.status})` + ); + + if (!this._initialized) { + const battType = payload.length === 1 ? 1 : 3; + this._initialize(battType); + } + + this._callbacks.updateBatteryProps({ + battery1Level: bat1.level, + battery1Status: bat1.status, + battery2Level: bat2.level, + battery2Status: bat2.status, + battery3Level: bat3.level, + battery3Status: bat3.status, + }); + } else if (group === MessageGroup.HEARABLE_CONTROL && + code === HearableControlMessage.NOTIFY_ANC_STATE) { + if (payload.length < 4) + return; + + const version = payload[0]; + const uiToggles = payload[1]; + const settableToggles = payload[2]; + const currentState = payload[3]; + + this._log.info( + `Parsed GFPS ANC Notification: Version=${version}, UI=${uiToggles.toString(16)}, ` + + `Settable=${settableToggles.toString(16)}, State=${currentState.toString(16)}` + ); + + this._lastVersion = version; + this._lastUiToggles = uiToggles; + this._lastSettableToggles = settableToggles; + + this._callbacks.updateNoiseControl(currentState, settableToggles); + } + } + + _requestAncState() { + this._log.info('GFPS connected, querying current ANC state'); + // Query the current ANC state: Group 0x08, Code 0x11, Length 0 + const queryBytes = new Uint8Array([MessageGroup.HEARABLE_CONTROL, + HearableControlMessage.GET_ANC_STATE, 0x00, 0x00]); + + this.sendMessage(queryBytes); + } + + _initialize(battType) { + this._initialized = true; + this._callbacks.updateInitDevice(battType); + this._requestAncState(); + } + + sendAncState(targetState) { + // Construct the 4-byte payload: [Version, UI toggles, Settable toggles, Target state] + const payload = new Uint8Array([ + this._lastVersion, + this._lastUiToggles, + this._lastSettableToggles, + targetState, + ]); + + const header = new Uint8Array([ + MessageGroup.HEARABLE_CONTROL, + HearableControlMessage.SET_ANC_STATE, + 0x00, + payload.length, + ]); + + const packet = new Uint8Array(header.length + payload.length); + packet.set(header, 0); + packet.set(payload, header.length); + + this.sendMessage(packet); + } +}); diff --git a/bbm/lib/devices/googleBuds/googleBudsConfig.js b/bbm/lib/devices/googleBuds/googleBudsConfig.js new file mode 100644 index 0000000000..99788a9423 --- /dev/null +++ b/bbm/lib/devices/googleBuds/googleBudsConfig.js @@ -0,0 +1,115 @@ +'use strict'; + +export const DeviceTypeGoogleBuds = 'googleBuds'; + +export const MaestroUUID = '25e97ff7-24ce-4c4c-8951-f764a708f7b5'; + +export const PacketType = { + REQUEST: 0, + RESPONSE: 1, + CLIENT_ERROR: 4, + SERVER_ERROR: 5, + SERVER_STREAM: 7, +}; + +export const Status = { + OK: 0, + FAILED_PRECONDITION: 9, +}; + +export const MaestroService = 0x7ede71ea; + +export const MaestroMethod = { + GET_SOFTWARE_INFO: 0x7199fa44, + SUBSCRIBE_RUNTIME_INFO: 0xe61e8290, + SUBSCRIBE_SETTINGS_CHANGES: 0x2821adf5, + WRITE_SETTING: 0x9e8c9a1d, + READ_SETTING: 0xaed0ae51, +}; + +export const BatteryState = { + UNKNOWN: 0, + NOT_CHARGING: 1, + CHARGING: 2, +}; + +export const SettingId = { + ANCR_GESTURE_LOOP: 12, + CURRENT_ANCR_STATE: 13, + VOLUME_EQ_ENABLE: 15, + CURRENT_USER_EQ: 16, + LAST_SAVED_USER_EQ: 18, +}; + +export const AncState = { + OFF: 1, + ACTIVE: 2, + AWARE: 3, + ADAPTIVE: 4, +}; + +export const EqPreset = { + DEFAULT: 'default', + HEAVY_BASS: 'heavy-bass', + LIGHT_BASS: 'light-bass', + BALANCED: 'balanced', + VOCAL_BOOST: 'vocal-boost', + CLARITY: 'clarity', + LAST_SAVED: 'last-saved', + CUSTOM: 'custom', +}; + +// Presets are applied by writing these bands to CURRENT_USER_EQ. +// LAST_SAVED_USER_EQ is read separately at runtime. +export const EqPresetBands = { + [EqPreset.DEFAULT]: [0, 0, 0, 0, 0], + [EqPreset.HEAVY_BASS]: [5, 3, 0, 0, 0], + [EqPreset.LIGHT_BASS]: [-5, -1, 0, 0, 0], + [EqPreset.BALANCED]: [-3, 1, 1, -1, 3], + [EqPreset.VOCAL_BOOST]: [-1, 0, 4, 2, 0], + [EqPreset.CLARITY]: [-2, 0, 2, 3, 5], +}; + +export function eqPresetForBands(eqBands) { + const roundedBands = eqBands.map(value => Math.round(value)); + + for (const [preset, bands] of Object.entries(EqPresetBands)) { + if (JSON.stringify(roundedBands) === JSON.stringify(bands)) + return preset; + } + + return EqPreset.CUSTOM; +} + +export const Peer = { + CASE: 2, + LEFT_BT_CORE: 3, + RIGHT_BT_CORE: 4, + MAESTRO_A: 10, + MAESTRO_B: 13, +}; + +export const CandidateChannels = [18, 19, 21, 23, 24, 26]; + +export function addressForChannel(channel) { + switch (channel) { + case 18: + return addressFromPeers(Peer.MAESTRO_A, Peer.CASE); + case 19: + return addressFromPeers(Peer.MAESTRO_A, Peer.LEFT_BT_CORE); + case 21: + return addressFromPeers(Peer.MAESTRO_A, Peer.RIGHT_BT_CORE); + case 23: + return addressFromPeers(Peer.MAESTRO_B, Peer.CASE); + case 24: + return addressFromPeers(Peer.MAESTRO_B, Peer.LEFT_BT_CORE); + case 26: + return addressFromPeers(Peer.MAESTRO_B, Peer.RIGHT_BT_CORE); + default: + return null; + } +} + +function addressFromPeers(source, target) { + return (source & 0x0f) << 6 | (target & 0x0f) << 10; +} diff --git a/bbm/lib/devices/googleBuds/googleBudsDevice.js b/bbm/lib/devices/googleBuds/googleBudsDevice.js new file mode 100644 index 0000000000..08ced7d343 --- /dev/null +++ b/bbm/lib/devices/googleBuds/googleBudsDevice.js @@ -0,0 +1,435 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import { + buds2to1BatteryLevel, launchConfigureWindow, validateProperties +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {GoogleBudsSocket} from './googleBudsSocket.js'; +import { + AncState, DeviceTypeGoogleBuds, EqPreset, MaestroUUID, eqPresetForBands +} from './googleBudsConfig.js'; + +export {DeviceTypeGoogleBuds}; + +export function isGoogleBuds(bluezDeviceProxy, uuids) { + const bluezProps = []; + let supported = 'no'; + + if (uuids.includes(MaestroUUID)) + supported = 'yes'; + + return {supported, bluezProps}; +} + +export const GoogleBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GoogleBudsDevice', +}, class GoogleBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GoogleBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- GoogleBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._ignoreGsettingsChange = false; + + this._battInfoRecieved = false; + this._config = createConfig(); + this._props = createProperties(); + + this._callbacks = { + updateFirmwareInfo: this.updateFirmwareInfo.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateAncState: this.updateAncState.bind(this), + updateAncGestureLoop: this.updateAncGestureLoop.bind(this), + updateVolumeEqEnable: this.updateVolumeEqEnable.bind(this), + updateEq: this.updateEq.bind(this), + updateLastSavedEq: this.updateLastSavedEq.bind(this), + }; + + this._initialize(profileManager); + } + + _initialize(profileManager) { + this._commonIcon = 'earbuds'; + this._caseIcon = 'case-oval'; + + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery3Icon = this._caseIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.battery2ShowOnDisconnect = true; + this._config.showSettingsButton = true; + this._updateAncConfig(); + this._createDefaultSettings(); + this._ensureSettings(); + this._updateInitialValues(); + this._monitorGoogleBudsListGsettings(); + + const profile = {type: DeviceTypeGoogleBuds, uuid: MaestroUUID}; + + this._googleBudsSocket = new GoogleBudsSocket( + this._devicePath, + profileManager, + profile, + this._callbacks + ); + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + name: 'Pixel Buds', + alias: this._alias, + icon: this._commonIcon, + 'fw-version': '', + 'volume-eq': false, + 'eq-preset': EqPreset.CUSTOM, + 'eq-custom': [0, 0, 0, 0, 0], + 'eq-last-saved': [0, 0, 0, 0, 0], + }; + } + + _ensureSettings() { + const devicesList = this._settings.get_strv('google-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('google-buds-list', devicesList.map(JSON.stringify)); + return; + } + + validateProperties(this._settings, 'google-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('google-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + this._commonIcon = this._settingsItems.icon; + this._volumeEq = this._settingsItems['volume-eq']; + this._eqPreset = this._settingsItems['eq-preset']; + this._eqCustom = this._settingsItems['eq-custom']; + this._eqLastSaved = this._settingsItems['eq-last-saved']; + this._updateIcons(); + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery3Icon = this._caseIcon; + this.dataHandler?.setConfig(this._config); + } + + _monitorGoogleBudsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::google-buds-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('google-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems.icon; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + const volumeEq = this._settingsItems['volume-eq']; + if (this._volumeEq !== volumeEq) { + this._volumeEq = volumeEq; + this._googleBudsSocket?.setVolumeEqEnable(volumeEq); + } + + this._eqPreset = this._settingsItems['eq-preset']; + this._eqLastSaved = this._settingsItems['eq-last-saved']; + + const eqCustom = this._settingsItems['eq-custom']; + if (JSON.stringify(this._eqCustom) !== JSON.stringify(eqCustom)) { + this._eqCustom = eqCustom; + this._googleBudsSocket?.setEq(eqCustom); + } + } + + _updateGsettings() { + if (!this._settingsItems) + return; + + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('google-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('google-buds-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _updateAncConfig(enabledModes = {off: true, aware: true, active: true, adaptive: false}) { + this._ancEnabledModes = {...enabledModes}; + this._config.toggle1Title = _('Noise Control'); + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Icon`] = null; + this._config[`toggle1Button${i}Name`] = ''; + } + + const modes = [ + { + enabled: enabledModes.off, + state: AncState.OFF, + icon: 'bbm-anc-off-symbolic', + name: _('Off'), + }, + { + enabled: enabledModes.aware, + state: AncState.AWARE, + icon: 'bbm-transperancy-symbolic', + name: _('Transparency'), + }, + { + enabled: enabledModes.active, + state: AncState.ACTIVE, + icon: 'bbm-anc-on-symbolic', + name: _('Noise Cancellation'), + }, + { + enabled: enabledModes.adaptive, + state: AncState.ADAPTIVE, + icon: 'bbm-adaptive-symbolic', + name: _('Adaptive'), + }, + ]; + + this._toggle1ButtonToAncState = {}; + let button = 1; + + for (const mode of modes) { + if (!mode.enabled) + continue; + + this._config[`toggle1Button${button}Icon`] = mode.icon; + this._config[`toggle1Button${button}Name`] = mode.name; + this._toggle1ButtonToAncState[button] = mode.state; + button++; + } + + this._ancStateToToggle1Button = Object.fromEntries( + Object.entries(this._toggle1ButtonToAncState) + .map(([button, state]) => [state, Number(button)]) + ); + + if (this._props.toggle1State && + !this._toggle1ButtonToAncState[this._props.toggle1State]) + this._props.toggle1State = 0; + + this.dataHandler?.setConfig(this._config); + } + + updateAncGestureLoop(gestureLoop) { + const enabledModes = { + off: true, + aware: true, + active: true, + adaptive: gestureLoop.adaptive || this._ancState === AncState.ADAPTIVE, + }; + this._updateAncConfig(enabledModes); + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + this._props.toggle1Visible = true; + this.dataHandler = new DataHandler(this._config, this._props); + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateFirmwareInfo(fwVersions) { + const arr = fwVersions ?? []; + + if (arr.length === 0) { + this._settingsItems['fw-version'] = ''; + this._updateGsettings(); + return; + } + + let best = arr[0]; + let bestCount = 0; + + for (const v of arr) { + const count = arr.filter(x => x === v).length; + + if (count > bestCount) { + best = v; + bestCount = count; + } + } + + const allSame = arr.every(v => v === arr[0]); + + this._settingsItems['fw-version'] = best; + this._updateGsettings(); + + if (allSame) + this._log.info(`Firmware: ${best}`); + else + this._log.info(`Firmware mismatch: ${arr.join(', ')} -> using ${best}`); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + this._props.computedBatteryLevel = buds2to1BatteryLevel(this._props); + + if (!this._battInfoRecieved) + this._startConfiguration(this._props); + + this.dataHandler?.setProps(this._props); + } + + updateAncState(ancState) { + this._ancState = ancState; + if (ancState === AncState.ADAPTIVE && !this._ancStateToToggle1Button?.[ancState]) + this._updateAncConfig({...this._ancEnabledModes, adaptive: true}); + + const toggleState = this._ancStateToToggle1Button?.[ancState] ?? 0; + this._props.toggle1State = toggleState; + this.dataHandler?.setProps(this._props); + } + + updateVolumeEqEnable(enabled) { + if (this._volumeEq === enabled) + return; + + this._volumeEq = enabled; + + if (this._settingsItems) { + this._settingsItems['volume-eq'] = enabled; + this._updateGsettings(); + } + } + + updateEq(eqBands) { + const roundedBands = eqBands.map(value => Math.round(value)); + const preset = this._eqPresetForBands(roundedBands); + if (this._eqPreset === preset && + JSON.stringify(this._eqCustom) === JSON.stringify(roundedBands)) + return; + + this._eqPreset = preset; + this._eqCustom = roundedBands; + + if (this._settingsItems) { + this._settingsItems['eq-preset'] = preset; + this._settingsItems['eq-custom'] = roundedBands; + this._updateGsettings(); + } + } + + updateLastSavedEq(eqBands) { + const roundedBands = eqBands.map(value => Math.round(value)); + const lastSavedChanged = + JSON.stringify(this._eqLastSaved) !== JSON.stringify(roundedBands); + + this._eqLastSaved = roundedBands; + const preset = this._eqPresetForBands(this._eqCustom); + + if (!lastSavedChanged && this._eqPreset === preset) + return; + + this._eqPreset = preset; + + if (this._settingsItems) { + this._settingsItems['eq-last-saved'] = roundedBands; + this._settingsItems['eq-preset'] = preset; + this._updateGsettings(); + } + } + + _eqPresetForBands(eqBands) { + const staticPreset = eqPresetForBands(eqBands); + if (staticPreset !== EqPreset.CUSTOM) + return staticPreset; + + if (JSON.stringify(eqBands) === JSON.stringify(this._eqLastSaved)) + return EqPreset.LAST_SAVED; + + return EqPreset.CUSTOM; + } + + _toggle1ButtonClicked(index) { + const ancState = this._toggle1ButtonToAncState?.[index]; + if (!ancState) + return; + + this._props.toggle1State = index; + this.dataHandler?.setProps(this._props); + this._googleBudsSocket?.setAncState(ancState); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, DeviceTypeGoogleBuds, this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + if (this._dataHandlerId && this.dataHandler) + this.dataHandler.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + this._googleBudsSocket?.destroy(); + this._googleBudsSocket = null; + this.dataHandler = null; + this._settings = null; + } +}); diff --git a/bbm/lib/devices/googleBuds/googleBudsProtocol.js b/bbm/lib/devices/googleBuds/googleBudsProtocol.js new file mode 100644 index 0000000000..0d0a88011c --- /dev/null +++ b/bbm/lib/devices/googleBuds/googleBudsProtocol.js @@ -0,0 +1,519 @@ +'use strict'; + +import {addressForChannel} from './googleBudsConfig.js'; + +const FRAME = 0x7e; +const ESCAPE = 0x7d; +const ESCAPE_MASK = 0x20; +const CONTROL = 0x03; + +const CRC32_TABLE = (() => { + const table = []; + for (let i = 0; i < 256; i++) { + let crc = i; + for (let j = 0; j < 8; j++) + crc = crc & 1 ? 0xedb88320 ^ crc >>> 1 : crc >>> 1; + table.push(crc >>> 0); + } + return table; +})(); + +export class HdlcCodec { + constructor() { + this._buffer = []; + this._inFrame = false; + this._escaped = false; + } + + decode(bytes) { + const frames = []; + + for (const byte of bytes) { + if (!this._inFrame) { + if (byte === FRAME) { + this._inFrame = true; + this._buffer = []; + this._escaped = false; + } + continue; + } + + if (byte === FRAME) { + const frame = this._decodeBuffered(); + if (frame) + frames.push(frame); + this._buffer = []; + this._escaped = false; + this._inFrame = true; + continue; + } + + if (this._escaped) { + this._buffer.push(byte ^ ESCAPE_MASK); + this._escaped = false; + continue; + } + + if (byte === ESCAPE) { + this._escaped = true; + continue; + } + + this._buffer.push(byte); + } + + return frames; + } + + encode(channel, data) { + const address = addressForChannel(channel); + if (address === null) + return null; + + const payload = [ + ...encodeAddress(address), + CONTROL, + ...data, + ]; + const crc = crc32(payload); + payload.push(crc & 0xff, crc >>> 8 & 0xff, crc >>> 16 & 0xff, crc >>> 24 & 0xff); + + const out = [FRAME]; + for (const byte of payload) { + if (byte === FRAME || byte === ESCAPE) + out.push(ESCAPE, byte ^ ESCAPE_MASK); + else + out.push(byte); + } + out.push(FRAME); + + return Uint8Array.from(out); + } + + _decodeBuffered() { + if (this._buffer.length < 6) + return null; + + const body = this._buffer.slice(0, -4); + const expected = this._buffer.at(-4) | + this._buffer.at(-3) << 8 | + this._buffer.at(-2) << 16 | + this._buffer.at(-1) << 24; + if ((crc32(body) | 0) !== (expected | 0)) + return null; + + const [address, offset] = decodeAddress(body, 0); + if (address === null || body[offset] !== CONTROL) + return null; + + return { + address, + data: body.slice(offset + 1), + }; + } +} + +export function encodeRpcPacket(packet) { + const out = []; + writeVarintField(out, 1, packet.type); + writeVarintField(out, 2, packet.channelId); + writeFixed32Field(out, 3, packet.serviceId); + writeFixed32Field(out, 4, packet.methodId); + writeBytesField(out, 5, packet.payload ?? []); + writeVarintField(out, 6, packet.status ?? 0); + writeVarintField(out, 7, packet.callId); + return out; +} + +export function decodeRpcPacket(bytes) { + const packet = { + type: 0, + channelId: 0, + serviceId: 0, + methodId: 0, + payload: [], + status: 0, + callId: 0, + }; + + for (const field of readFields(bytes)) { + switch (field.number) { + case 1: + packet.type = readVarintValue(field); + break; + case 2: + packet.channelId = readVarintValue(field); + break; + case 3: + packet.serviceId = readFixed32Value(field); + break; + case 4: + packet.methodId = readFixed32Value(field); + break; + case 5: + packet.payload = field.value; + break; + case 6: + packet.status = readVarintValue(field); + break; + case 7: + packet.callId = readVarintValue(field); + break; + } + } + + return packet; +} + +export function decodeRuntimeInfo(bytes) { + const props = {}; + + const runtimeFields = readFields(bytes); + const batteryField = runtimeFields.find(field => field.number === 6 && field.wireType === 2); + if (!batteryField) + return props; + + for (const field of readFields(batteryField.value)) { + if (field.wireType !== 2) + continue; + + const battery = decodeDeviceBatteryInfo(field.value); + if (!battery) + continue; + + const index = batteryFieldToIndex(field.number); + if (!index) + continue; + + props[`battery${index}Level`] = battery.level; + props[`battery${index}Status`] = battery.status; + } + + return props; +} + +export function encodeReadSettingPayload(settingId) { + const out = []; + writeVarintField(out, 4, settingId); + return out; +} + +export function encodeWriteAncStatePayload(ancState) { + const setting = []; + writeVarintField(setting, 13, ancState); + + const out = []; + writeBytesField(out, 4, setting); + return out; +} + +export function encodeWriteVolumeEqEnablePayload(enabled) { + const setting = []; + writeVarintField(setting, 15, enabled ? 1 : 0); + + const out = []; + writeBytesField(out, 4, setting); + return out; +} + +export function encodeWriteEqPayload(eqBands) { + const eq = []; + for (let i = 0; i < 5; i++) + writeFloatField(eq, i + 1, eqBands[i] ?? 0); + + const setting = []; + writeBytesField(setting, 16, eq); + + const out = []; + writeBytesField(out, 4, setting); + return out; +} + +export function decodeAncStateSettingsResponse(bytes) { + const settingsRsp = readFields(bytes); + const valueField = settingsRsp.find(field => field.number === 4 && field.wireType === 2); + if (!valueField) + return null; + + const settingValue = readFields(valueField.value); + const ancField = settingValue.find(field => field.number === 13 && field.wireType === 0); + if (!ancField) + return null; + + return readVarintValue(ancField); +} + +export function decodeAncGestureLoopSettingsResponse(bytes) { + const settingsRsp = readFields(bytes); + const valueField = settingsRsp.find(field => field.number === 4 && field.wireType === 2); + if (!valueField) + return null; + + const settingValue = readFields(valueField.value); + const gestureLoopField = + settingValue.find(field => field.number === 12 && field.wireType === 2); + + if (!gestureLoopField) + return null; + + const loop = { + active: false, + off: false, + aware: false, + adaptive: false, + }; + + for (const field of readFields(gestureLoopField.value)) { + if (field.wireType !== 0) + continue; + + const enabled = readVarintValue(field) !== 0; + if (field.number === 1) + loop.active = enabled; + else if (field.number === 2) + loop.off = enabled; + else if (field.number === 3) + loop.aware = enabled; + else if (field.number === 4) + loop.adaptive = enabled; + } + + return loop; +} + +export function decodeVolumeEqEnableSettingsResponse(bytes) { + const settingsRsp = readFields(bytes); + const valueField = settingsRsp.find(field => field.number === 4 && field.wireType === 2); + if (!valueField) + return null; + + const settingValue = readFields(valueField.value); + const volumeEqField = settingValue.find(field => field.number === 15 && field.wireType === 0); + if (!volumeEqField) + return null; + + return readVarintValue(volumeEqField) !== 0; +} + +export function decodeEqSettingsResponse(bytes, fieldNumber = 16) { + const settingsRsp = readFields(bytes); + const valueField = settingsRsp.find(field => field.number === 4 && field.wireType === 2); + if (!valueField) + return null; + + const settingValue = readFields(valueField.value); + const eqField = + settingValue.find(field => field.number === fieldNumber && field.wireType === 2); + + if (!eqField) + return null; + + const bands = [0, 0, 0, 0, 0]; + for (const field of readFields(eqField.value)) { + if (field.wireType !== 5 || field.number < 1 || field.number > 5) + continue; + + bands[field.number - 1] = readFloatValue(field); + } + + return bands.map(value => Math.max(-6, Math.min(6, value))); +} + +export function decodeFwVersion(bytes) { + const versions = []; + + for (const field of readFields(bytes)) { + if (field.wireType !== 2) + continue; + + const inner = readFields(field.value); + + for (const sub of inner) { + if (sub.wireType !== 2) + continue; + + const text = new TextDecoder().decode( + Uint8Array.from(sub.value) + ); + + const match = text.match(/\d+(?:\.\d+)+/); + if (match) + versions.push(match[0]); + } + } + + return versions; +} + +function decodeDeviceBatteryInfo(bytes) { + let level = null; + let state = null; + + for (const field of readFields(bytes)) { + if (field.number === 1) + level = readVarintValue(field); + else if (field.number === 2) + state = readVarintValue(field); + } + + if (level === null) + return null; + + return { + level: Math.max(0, Math.min(level, 100)), + status: state === 2 ? 'charging' : 'discharging', + }; +} + +function batteryFieldToIndex(fieldNumber) { + switch (fieldNumber) { + case 2: + return 1; + case 3: + return 2; + case 1: + return 3; + default: + return 0; + } +} + +function readFields(bytes) { + const fields = []; + let offset = 0; + + while (offset < bytes.length) { + const [key, keyOffset] = decodeVarint(bytes, offset); + if (key === null) + break; + + offset = keyOffset; + const number = key >>> 3; + const wireType = key & 0x07; + let value; + + if (wireType === 0) { + const [val, next] = decodeVarint(bytes, offset); + if (val === null) + break; + value = val; + offset = next; + } else if (wireType === 2) { + const [len, next] = decodeVarint(bytes, offset); + if (len === null || next + len > bytes.length) + break; + value = bytes.slice(next, next + len); + offset = next + len; + } else if (wireType === 5) { + if (offset + 4 > bytes.length) + break; + value = bytes.slice(offset, offset + 4); + offset += 4; + } else { + break; + } + + fields.push({number, wireType, value}); + } + + return fields; +} + +function readVarintValue(field) { + return field.wireType === 0 ? field.value : null; +} + +function readFixed32Value(field) { + if (field.wireType !== 5 || field.value.length !== 4) + return null; + return (field.value[0] | + field.value[1] << 8 | + field.value[2] << 16 | + field.value[3] << 24) >>> 0; +} + +function readFloatValue(field) { + if (field.wireType !== 5 || field.value.length !== 4) + return null; + + const array = Uint8Array.from(field.value); + return new DataView(array.buffer).getFloat32(0, true); +} + +function writeVarintField(out, number, value) { + out.push(...encodeVarint(number << 3), ...encodeVarint(value)); +} + +function writeFixed32Field(out, number, value) { + out.push(...encodeVarint(number << 3 | 5), value & 0xff, value >>> 8 & 0xff, + value >>> 16 & 0xff, value >>> 24 & 0xff); +} + +function writeFloatField(out, number, value) { + const buffer = new ArrayBuffer(4); + new DataView(buffer).setFloat32(0, Math.max(-6, Math.min(6, value)), true); + out.push(...encodeVarint(number << 3 | 5), ...new Uint8Array(buffer)); +} + +function writeBytesField(out, number, bytes) { + out.push(...encodeVarint(number << 3 | 2), ...encodeVarint(bytes.length), ...bytes); +} + +function encodeVarint(value) { + let val = value >>> 0; + const out = []; + while (val >= 0x80) { + out.push(val & 0x7f | 0x80); + val >>>= 7; + } + out.push(val); + return out; +} + +function encodeAddress(value) { + let val = value >>> 0; + const out = []; + while (val >>> 7 !== 0) { + out.push((val & 0x7f) << 1); + val >>>= 7; + } + out.push((val & 0x7f) << 1 | 1); + return out; +} + +function decodeVarint(bytes, offset) { + let value = 0; + let shift = 0; + + for (let i = offset; i < bytes.length && shift < 64; i++) { + const byte = bytes[i]; + value += (byte & 0x7f) * 2 ** shift; + if ((byte & 0x80) === 0) + return [value, i + 1]; + shift += 7; + } + + return [null, offset]; +} + +function decodeAddress(bytes, offset) { + let value = 0; + let shift = 0; + + for (let i = offset; i < bytes.length && shift < 35; i++) { + const byte = bytes[i]; + value |= byte >>> 1 << shift; + if ((byte & 0x01) === 0x01) + return [value >>> 0, i + 1]; + shift += 7; + } + + return [null, offset]; +} + +function crc32(bytes) { + let crc = 0xffffffff; + for (const byte of bytes) + crc = CRC32_TABLE[(crc ^ byte) & 0xff] ^ crc >>> 8; + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/bbm/lib/devices/googleBuds/googleBudsSocket.js b/bbm/lib/devices/googleBuds/googleBudsSocket.js new file mode 100644 index 0000000000..3460309e24 --- /dev/null +++ b/bbm/lib/devices/googleBuds/googleBudsSocket.js @@ -0,0 +1,525 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import { + CandidateChannels, MaestroMethod, MaestroService, PacketType, SettingId, Status +} from './googleBudsConfig.js'; +import { + HdlcCodec, decodeAncGestureLoopSettingsResponse, decodeAncStateSettingsResponse, + decodeEqSettingsResponse, decodeRpcPacket, decodeRuntimeInfo, decodeFwVersion, + decodeVolumeEqEnableSettingsResponse, encodeReadSettingPayload, encodeRpcPacket, + encodeWriteAncStatePayload, encodeWriteEqPayload, encodeWriteVolumeEqEnablePayload +} from './googleBudsProtocol.js'; + +const SOFTWARE_INFO_CALL_ID = 0xffffffff; +const RUNTIME_INFO_CALL_ID = 1; +const READ_ANC_STATE_CALL_ID = 2; +const READ_ANC_GESTURE_LOOP_CALL_ID = 3; +const SETTINGS_CHANGES_CALL_ID = 4; +const READ_VOLUME_EQ_CALL_ID = 5; +const READ_EQ_CALL_ID = 6; +const READ_LAST_SAVED_EQ_CALL_ID = 7; +const WRITE_SETTING_CALL_ID_BASE = 100; +const WRITE_RESPONSE_TIMEOUT_MS = 3000; +const WRITE_REFRESH_TIMEOUT_MS = 3000; + +export const GoogleBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_GoogleBudsSocket', +}, class GoogleBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `GoogleBudsSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('GoogleBudsSocket init'); + + this._codec = new HdlcCodec(); + this._callbacks = callbacks; + this._channel = null; + this._writeCallId = WRITE_SETTING_CALL_ID_BASE; + this._ancReadAfterRuntime = false; + this._pendingWriteSettings = new Map(); + this._writeQueue = []; + this._writeInFlight = false; + this._resumeWritesAfterReadCallId = null; + this._resumeWritesAfterReadSetting = null; + this._writeResponseTimeoutId = 0; + this._writeRefreshTimeoutId = 0; + + this.startSocket(); + } + + postConnectInitialization() { + this._log.info('Waiting for Maestro software-info response'); + } + + processData(bytes) { + for (const frame of this._codec.decode(bytes)) { + try { + this._processPacket(decodeRpcPacket(frame.data)); + } catch (e) { + this._log.error(e, 'Failed to decode Maestro packet'); + } + } + } + + _processPacket(packet) { + if (this._channel === null && this._isSoftwareInfoResponse(packet)) { + this._channel = packet.channelId; + this._log.info(`Resolved Maestro channel ${this._channel}`); + + const fwVersions = decodeFwVersion(packet.payload); + this._callbacks?.updateFirmwareInfo?.(fwVersions); + + this._subscribeRuntimeInfo(); + this._subscribeSettingsChanges(); + return; + } + + if (this._channel !== null && packet.type === PacketType.SERVER_STREAM && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.SUBSCRIBE_RUNTIME_INFO && + packet.callId === RUNTIME_INFO_CALL_ID) { + const props = decodeRuntimeInfo(packet.payload); + if (Object.keys(props).length > 0) + this._callbacks?.updateBatteryProps?.(props); + if (!this._ancReadAfterRuntime) { + this._ancReadAfterRuntime = true; + this.readAncGestureLoop(); + } + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === this._resumeWritesAfterReadCallId) { + this._processWriteRefreshResponse(packet); + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === READ_ANC_STATE_CALL_ID) { + if (packet.status === Status.OK) { + const ancState = decodeAncStateSettingsResponse(packet.payload); + if (ancState !== null) + this._callbacks?.updateAncState?.(ancState); + } + if (this._resumeWritesAfterReadCallId === READ_ANC_STATE_CALL_ID) { + this._completeWriteRefresh(); + return; + } + this.readVolumeEqEnable(); + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === READ_ANC_GESTURE_LOOP_CALL_ID) { + if (packet.status === Status.OK) { + const gestureLoop = decodeAncGestureLoopSettingsResponse(packet.payload); + if (gestureLoop !== null) + this._callbacks?.updateAncGestureLoop?.(gestureLoop); + } + this.readAncState(); + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === READ_VOLUME_EQ_CALL_ID) { + if (packet.status === Status.OK) { + const enabled = decodeVolumeEqEnableSettingsResponse(packet.payload); + if (enabled !== null) + this._callbacks?.updateVolumeEqEnable?.(enabled); + } + if (this._resumeWritesAfterReadCallId === READ_VOLUME_EQ_CALL_ID) { + this._completeWriteRefresh(); + return; + } + this.readEq(); + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === READ_EQ_CALL_ID) { + if (packet.status === Status.OK) { + const eq = decodeEqSettingsResponse(packet.payload); + if (eq !== null) + this._callbacks?.updateEq?.(eq); + } + if (this._resumeWritesAfterReadCallId === READ_EQ_CALL_ID) { + this._completeWriteRefresh(); + return; + } + this.readLastSavedEq(); + return; + } + + if (this._channel !== null && packet.type === PacketType.RESPONSE && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.READ_SETTING && + packet.callId === READ_LAST_SAVED_EQ_CALL_ID) { + if (packet.status === Status.OK) { + const eq = decodeEqSettingsResponse(packet.payload, SettingId.LAST_SAVED_USER_EQ); + if (eq !== null) + this._callbacks?.updateLastSavedEq?.(eq); + } else { + this._log.info(`Last saved EQ read failed with status ${packet.status}`); + } + return; + } + + if (this._channel !== null && packet.type === PacketType.SERVER_STREAM && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.SUBSCRIBE_SETTINGS_CHANGES && + packet.callId === SETTINGS_CHANGES_CALL_ID) { + this._processSettingsPayload(packet.payload); + return; + } + + if (this._channel !== null && + (packet.type === PacketType.RESPONSE || packet.type === PacketType.SERVER_ERROR) && + packet.channelId === this._channel && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.WRITE_SETTING && + packet.callId >= WRITE_SETTING_CALL_ID_BASE) { + if (packet.status !== Status.OK) + this._log.info(`Maestro setting write failed with status ${packet.status}`); + this._refreshWrittenSetting(packet.callId); + return; + } + + if (packet.type === PacketType.SERVER_STREAM) + this._sendClientError(packet, Status.FAILED_PRECONDITION); + } + + _isSoftwareInfoResponse(packet) { + return packet.type === PacketType.RESPONSE && + CandidateChannels.includes(packet.channelId) && + packet.serviceId === MaestroService && + packet.methodId === MaestroMethod.GET_SOFTWARE_INFO && + packet.callId === SOFTWARE_INFO_CALL_ID; + } + + _subscribeRuntimeInfo() { + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.SUBSCRIBE_RUNTIME_INFO, + payload: [], + status: Status.OK, + callId: RUNTIME_INFO_CALL_ID, + }); + } + + _subscribeSettingsChanges() { + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.SUBSCRIBE_SETTINGS_CHANGES, + payload: [], + status: Status.OK, + callId: SETTINGS_CHANGES_CALL_ID, + }); + } + + readAncState() { + if (this._channel === null) + return; + + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.READ_SETTING, + payload: encodeReadSettingPayload(SettingId.CURRENT_ANCR_STATE), + status: Status.OK, + callId: READ_ANC_STATE_CALL_ID, + }); + } + + readAncGestureLoop() { + if (this._channel === null) + return; + + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.READ_SETTING, + payload: encodeReadSettingPayload(SettingId.ANCR_GESTURE_LOOP), + status: Status.OK, + callId: READ_ANC_GESTURE_LOOP_CALL_ID, + }); + } + + readVolumeEqEnable() { + if (this._channel === null) + return; + + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.READ_SETTING, + payload: encodeReadSettingPayload(SettingId.VOLUME_EQ_ENABLE), + status: Status.OK, + callId: READ_VOLUME_EQ_CALL_ID, + }); + } + + readEq() { + if (this._channel === null) + return; + + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.READ_SETTING, + payload: encodeReadSettingPayload(SettingId.CURRENT_USER_EQ), + status: Status.OK, + callId: READ_EQ_CALL_ID, + }); + } + + readLastSavedEq() { + if (this._channel === null) + return; + + this._readSetting(SettingId.LAST_SAVED_USER_EQ, READ_LAST_SAVED_EQ_CALL_ID); + } + + _readSetting(settingId, callId) { + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.READ_SETTING, + payload: encodeReadSettingPayload(settingId), + status: Status.OK, + callId, + }); + } + + setAncState(ancState) { + if (this._channel === null) + return; + + this._queueWriteSetting('anc', encodeWriteAncStatePayload(ancState)); + } + + setVolumeEqEnable(enabled) { + if (this._channel === null) + return; + + this._queueWriteSetting('volumeEq', encodeWriteVolumeEqEnablePayload(enabled)); + } + + setEq(eqBands) { + if (this._channel === null) + return; + + this._queueWriteSetting('eq', encodeWriteEqPayload(eqBands)); + } + + _queueWriteSetting(setting, payload) { + this._writeQueue.push({setting, payload}); + this._sendNextWriteSetting(); + } + + _sendNextWriteSetting() { + if (this._writeInFlight || this._resumeWritesAfterReadCallId !== null || + this._writeQueue.length === 0 || this._channel === null) + return; + + const {setting, payload} = this._writeQueue.shift(); + this._writeCallId++; + this._pendingWriteSettings.set(this._writeCallId, setting); + this._writeInFlight = true; + this._startWriteResponseTimeout(this._writeCallId, setting); + this._sendRpcPacket({ + type: PacketType.REQUEST, + channelId: this._channel, + serviceId: MaestroService, + methodId: MaestroMethod.WRITE_SETTING, + payload, + status: Status.OK, + callId: this._writeCallId, + }); + } + + _refreshWrittenSetting(callId) { + if (!this._pendingWriteSettings.has(callId)) { + this._log.info(`Ignoring stale Maestro setting write response callId=${callId}`); + return; + } + + this._clearWriteResponseTimeout(); + const setting = this._pendingWriteSettings.get(callId); + this._pendingWriteSettings.delete(callId); + this._writeInFlight = false; + + this._resumeWritesAfterReadCallId = ++this._writeCallId; + this._resumeWritesAfterReadSetting = setting; + + if (setting === 'anc') { + this._startWriteRefreshTimeout(this._resumeWritesAfterReadCallId, setting); + this._readSetting(SettingId.CURRENT_ANCR_STATE, this._resumeWritesAfterReadCallId); + } else if (setting === 'volumeEq') { + this._startWriteRefreshTimeout(this._resumeWritesAfterReadCallId, setting); + this._readSetting(SettingId.VOLUME_EQ_ENABLE, this._resumeWritesAfterReadCallId); + } else if (setting === 'eq') { + this._startWriteRefreshTimeout(this._resumeWritesAfterReadCallId, setting); + this._readSetting(SettingId.CURRENT_USER_EQ, this._resumeWritesAfterReadCallId); + } else { + this._resumeWritesAfterReadCallId = null; + this._resumeWritesAfterReadSetting = null; + this._sendNextWriteSetting(); + } + } + + _startWriteResponseTimeout(callId, setting) { + this._clearWriteResponseTimeout(); + this._writeResponseTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WRITE_RESPONSE_TIMEOUT_MS, + () => { + this._writeResponseTimeoutId = 0; + if (!this._pendingWriteSettings.has(callId)) + return GLib.SOURCE_REMOVE; + + this._pendingWriteSettings.delete(callId); + this._writeInFlight = false; + this._log.info( + `Maestro setting write timed out setting=${setting} callId=${callId}` + ); + this._sendNextWriteSetting(); + return GLib.SOURCE_REMOVE; + } + ); + } + + _clearWriteResponseTimeout() { + if (this._writeResponseTimeoutId) { + GLib.Source.remove(this._writeResponseTimeoutId); + this._writeResponseTimeoutId = 0; + } + } + + _startWriteRefreshTimeout(readCallId, setting) { + this._clearWriteRefreshTimeout(); + this._writeRefreshTimeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + WRITE_REFRESH_TIMEOUT_MS, + () => { + this._writeRefreshTimeoutId = 0; + if (this._resumeWritesAfterReadCallId !== readCallId) + return GLib.SOURCE_REMOVE; + + this._resumeWritesAfterReadCallId = null; + this._resumeWritesAfterReadSetting = null; + this._log.info( + `Maestro setting refresh timed out setting=${setting} callId=${readCallId}` + ); + this._sendNextWriteSetting(); + return GLib.SOURCE_REMOVE; + } + ); + } + + _clearWriteRefreshTimeout() { + if (this._writeRefreshTimeoutId) { + GLib.Source.remove(this._writeRefreshTimeoutId); + this._writeRefreshTimeoutId = 0; + } + } + + _completeWriteRefresh() { + this._clearWriteRefreshTimeout(); + this._resumeWritesAfterReadCallId = null; + this._resumeWritesAfterReadSetting = null; + this._sendNextWriteSetting(); + } + + _processWriteRefreshResponse(packet) { + if (packet.status === Status.OK) { + if (this._resumeWritesAfterReadSetting === 'anc') { + const ancState = decodeAncStateSettingsResponse(packet.payload); + if (ancState !== null) + this._callbacks?.updateAncState?.(ancState); + } else if (this._resumeWritesAfterReadSetting === 'volumeEq') { + const enabled = decodeVolumeEqEnableSettingsResponse(packet.payload); + if (enabled !== null) + this._callbacks?.updateVolumeEqEnable?.(enabled); + } else if (this._resumeWritesAfterReadSetting === 'eq') { + const eq = decodeEqSettingsResponse(packet.payload); + if (eq !== null) + this._callbacks?.updateEq?.(eq); + } + } + this._completeWriteRefresh(); + } + + _processSettingsPayload(payload) { + const ancState = decodeAncStateSettingsResponse(payload); + if (ancState !== null) + this._callbacks?.updateAncState?.(ancState); + + const volumeEq = decodeVolumeEqEnableSettingsResponse(payload); + if (volumeEq !== null) + this._callbacks?.updateVolumeEqEnable?.(volumeEq); + + const eq = decodeEqSettingsResponse(payload); + if (eq !== null) + this._callbacks?.updateEq?.(eq); + } + + _sendClientError(packet, status) { + this._sendRpcPacket({ + type: PacketType.CLIENT_ERROR, + channelId: packet.channelId, + serviceId: packet.serviceId, + methodId: packet.methodId, + payload: [], + status, + callId: packet.callId, + }); + } + + _sendRpcPacket(packet) { + const bytes = encodeRpcPacket(packet); + const frame = this._codec.encode(packet.channelId, bytes); + if (frame) + this.sendMessage(frame); + } + + destroy() { + this._clearWriteResponseTimeout(); + this._clearWriteRefreshTimeout(); + this._writeQueue = []; + this._pendingWriteSettings.clear(); + super.destroy(); + this._codec = null; + } +}); diff --git a/bbm/lib/devices/logger.js b/bbm/lib/devices/logger.js new file mode 100644 index 0000000000..9d6d73ebab --- /dev/null +++ b/bbm/lib/devices/logger.js @@ -0,0 +1,84 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +const LOG_INFO = false; +const LOG_BYTES = false; + +const LOG_DIR = GLib.build_filenamev([GLib.get_tmp_dir(), 'bluetooth_battery_meter']); +export const LOG_PATH = `${LOG_DIR}/service.log`; +const LOG_HISTORY_PATH = `${LOG_DIR}/service-history.log`; +const MAX_LOG_BYTES = 1024 * 1024; +const FULL_SANITIZE_LOG = true; + +GLib.mkdir_with_parents(LOG_DIR, 0o755); + +const logFile = Gio.File.new_for_path(LOG_PATH); +const historyFile = Gio.File.new_for_path(LOG_HISTORY_PATH); + +function enforceLogSizeLimit() { + try { + const info = logFile.query_info('standard::size', Gio.FileQueryInfoFlags.NONE, null); + if (info.get_size() >= MAX_LOG_BYTES) { + if (historyFile.query_exists(null)) + historyFile.delete(null); + + logFile.move(historyFile, Gio.FileCopyFlags.OVERWRITE, null, null); + } + } catch { + // Do nothing + } +} + +function WriteLogLine(prefix, msg) { + enforceLogSizeLimit(); + const line = `[${new Date().toISOString()}] ${prefix}: ${msg}\n\n`; + const stream = logFile.append_to(Gio.FileCreateFlags.NONE, null); + const bytes = new GLib.Bytes(line); + stream.write_bytes(bytes, null); + stream.flush(null); + stream.close(null); +} + +export function createLogger(tag) { + return { + info: LOG_INFO + ? (...args) => WriteLogLine('INF', `[${tag}] ${args.join(' ')}`) + : () => {}, + error: (err, msg = '') => { + const text = `${msg} ${err instanceof Error ? err.stack : String(err)}`.trim(); + WriteLogLine('ERR', `[${tag}] ${text}`); + }, + bytes: LOG_BYTES + ? (...args) => WriteLogLine('BYT', `[${tag}] ${args.join(' ')}`) + : () => {}, + }; +} + +export function sanitizeDevPath(path) { + return path.replace( + /dev_(?:[0-9A-Fa-f]{2}_){5}([0-9A-Fa-f]{2})/, + FULL_SANITIZE_LOG ? 'dev_XX_XX_XX_XX_XX_XX' : 'dev_XX_XX_XX_XX_XX_$1' + ); +} + +export function getDeviceIdentifier(devicePath) { + return FULL_SANITIZE_LOG ? 'XX' : devicePath.slice(-2); +} + +export function hexBytes(value) { + if (typeof value === 'number') { + let hex = value.toString(16); + + if (hex.length % 2) + hex = `0${hex}`; + + return `0x${hex}`; + } + + if (Array.isArray(value) || ArrayBuffer.isView(value)) + return Array.from(value, b => b.toString(16).padStart(2, '0')).join(' '); + + return ''; +} + diff --git a/bbm/lib/devices/mediaController.js b/bbm/lib/devices/mediaController.js new file mode 100644 index 0000000000..d9a623ef96 --- /dev/null +++ b/bbm/lib/devices/mediaController.js @@ -0,0 +1,440 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import Gvc from 'gi://Gvc'; +import * as Volume from 'resource:///org/gnome/shell/ui/status/volume.js'; + +import {createLogger} from './logger.js'; + +const MEDIA_PLAYER_PREFIX = 'org.mpris.MediaPlayer2.'; + +export const MediaController = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_MediaController', + Properties: { + 'output-is-a2dp': GObject.ParamSpec.boolean( + 'output-is-a2dp', 'output-is-a2dp', '', GObject.ParamFlags.READWRITE, false + ), + }, +}, class MediaController extends GObject.Object { + _init(settings, devicePath, previousOnDestroyVolume) { + super._init(); + this._log = createLogger('MediaController'); + this._settings = settings; + this._devicePath = devicePath; + this._noSymbolMac = devicePath.substring(devicePath.indexOf('dev_') + 4) + .replace(/_/g, '').toUpperCase(); + + this._controllerReady = false; + this._previousVolume = previousOnDestroyVolume; + this._sink = null; + this._defaultSinkChangedId = null; + this._stateId = null; + this._volumeId = null; + this._muteId = null; + + this._mprisNames = null; + this._lastPausedPlayer = null; + this._playbackStatusChangePending = false; + + this._control = Volume.getMixerControl(); + if (!this._control) + return; + + if (this._control.get_state() === Gvc.MixerControlState.READY) { + this._onControlReady(); + this._controllerReady = true; + } else { + this._controllerReady = false; + } + + this._stateChangeId = this._control.connect('state-changed', () => { + if (this._control.get_state() === Gvc.MixerControlState.READY) { + this._onControlReady(); + this._controllerReady = true; + } else { + this._disconnectController(); + this._controllerReady = false; + } + }); + } + + _monitorSinkVolume() { + this._volumeId = this._sink?.connect('notify::volume', () => { + this._previousVolume = -1; + }); + } + + _initializeSink(sink) { + if (this._sink === sink) + return; + + this._unmonitorSink(); + + this._sink = sink; + + if (sink.get_state() === Gvc.MixerStreamState.RUNNING) + this._sinkStateIsRunning = true; + else + this._sinkStateIsRunning = false; + + + this._sinkIsMuted = this._sink.get_is_muted(); + + this._stateId = sink.connect('notify::state', () => { + const state = this._sink.get_state(); + if (state === Gvc.MixerStreamState.RUNNING) + this._sinkStateIsRunning = true; + else + this._sinkStateIsRunning = false; + }); + + this._muteId = sink.connect('notify::is-muted', () => { + this._sinkIsMuted = this._sink.get_is_muted(); + + if (this._sinkIsMuted) { + if (this._previousVolume >= 0) { + this._sink.set_volume(this._previousVolume); + this._sink.push_volume(); + this._previousVolume = -1; + } + } + }); + + this._monitorSinkVolume(); + } + + _unmonitorSinkVolume() { + if (this._volumeId) { + this._sink.disconnect(this._volumeId); + this._volumeId = null; + } + } + + _unmonitorSink() { + if (!this._sink) + return; + + if (this._stateId) { + this._sink.disconnect(this._stateId); + this._stateId = null; + } + + if (this._muteId) { + this._sink.disconnect(this._muteId); + this._muteId = null; + } + this._unmonitorSinkVolume(); + + this._sink = null; + } + + _findA2dpSinkForMac() { + const sinks = this._control.get_sinks(); + for (const sink of sinks) { + const name = sink.get_name() || ''; + const noSymbolName = name.replace(/[_\-.:]/g, '').toUpperCase(); + if (!noSymbolName.includes(this._noSymbolMac)) + continue; + const device = this._control.lookup_device_from_stream(sink); + // PipeWire names the A2DP sink profile per negotiated codec, e.g. + // 'a2dp-sink' (AAC), 'a2dp-sink-sbc', 'a2dp-sink-sbc_xq', + // 'a2dp-sink-aptx', 'a2dp-sink-ldac'. Match the family prefix so + // playback control works regardless of the active codec. + if (device?.get_active_profile()?.startsWith('a2dp-sink')) + return sink; + } + return null; + } + + _checkAndMonitorSink() { + const defaultSink = this._control.get_default_sink(); + const a2dpSink = this._findA2dpSinkForMac(); + + if (defaultSink && a2dpSink && defaultSink === a2dpSink) { + this.output_is_a2dp = true; + this.notify('output-is-a2dp'); + this._initializeSink(a2dpSink); + } else { + this.output_is_a2dp = false; + this.notify('output-is-a2dp'); + this._unmonitorSink(); + } + } + + _onControlReady() { + if (this._defaultSinkChangedId) + this._control.disconnect(this._defaultSinkChangedId); + + this._defaultSinkChangedId = this._control.connect('default-sink-changed', () => { + this._checkAndMonitorSink(); + }); + this._checkAndMonitorSink(); + } + + _disconnectController() { + this._unmonitorSink(); + if (this._defaultSinkChangedId) + this._control.disconnect(this._defaultSinkChangedId); + this._defaultSinkChangedId = null; + } + + setConversationAwarenessVolume(attenuated, caVolume) { + if (!this._controllerReady || !this._sink || !this._sinkStateIsRunning || this._sinkIsMuted) + return; + + if (this._attenuated === attenuated) + return; + + this._attenuated = attenuated; + + if (attenuated && this._previousVolume >= 0) + return; + + if (!attenuated && this._previousVolume < 0) + return; + + const easeInOutQuad = t => { + return t < 0.5 + ? 2 * t * t + : -1 + (4 - 2 * t) * t; + }; + + if (this._volumeRampTimeoutId) + GLib.source_remove(this._volumeRampTimeoutId); + this._volumeRampTimeoutId = null; + + if (attenuated) { + const maxVolume = this._control.get_vol_max_norm(); + const fadeOutTargetVolume = Math.floor(caVolume * maxVolume / 100); + const currentVolume = this._sink.volume; + + if (currentVolume <= fadeOutTargetVolume) + return; + + this._previousVolume = currentVolume; + const duration = 1000; + const steps = 50; + const interval = duration / steps; + let step = 0; + + this._unmonitorSinkVolume(); + + this._volumeRampTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => { + if (step >= steps) { + this._sink.set_volume(fadeOutTargetVolume); + this._sink.push_volume(); + this._monitorSinkVolume(); + this._volumeRampTimeoutId = null; + return GLib.SOURCE_REMOVE; + } + + const t = step / steps; + const eased = easeInOutQuad(t); + const newVolume = + Math.round(currentVolume + (fadeOutTargetVolume - currentVolume) * eased); + + this._sink.set_volume(newVolume); + this._sink.push_volume(); + + step++; + return GLib.SOURCE_CONTINUE; + }); + } else { + const currentVolume = this._sink.volume; + const fadeInTargetVolume = this._previousVolume; + const duration = 1000; + const steps = 50; + const interval = duration / steps; + let step = 0; + + this._unmonitorSinkVolume(); + + this._volumeRampTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, interval, () => { + if (step >= steps) { + this._sink.set_volume(fadeInTargetVolume); + this._sink.push_volume(); + this._previousVolume = -1; + this._monitorSinkVolume(); + this._volumeRampTimeoutId = null; + return GLib.SOURCE_REMOVE; + } + + const t = step / steps; + const eased = easeInOutQuad(t); + const newVolume = + Math.round(currentVolume + (fadeInTargetVolume - currentVolume) * eased); + this._sink.set_volume(newVolume); + this._sink.push_volume(); + + step++; + return GLib.SOURCE_CONTINUE; + }); + } + } + + _playerPropsChanged() { + if (this._playbackStatusChangePending) { + this._playbackStatusChangePending = false; + return; + } + const status = this._playerProxy?.get_cached_property('PlaybackStatus')?.unpack(); + if (status !== 'Paused') + this._lastPausedPlayer = null; + } + + async _changeStatus() { + if (this._playerProxy) { + if (this._requestedState === 'pause') { + try { + await this._playerProxy.call( + 'Pause', + null, + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (e) { + this._log.error(`Error call Mpris Pause method. Error: ${e}`); + } + const status = this._playerProxy?.get_cached_property('PlaybackStatus')?.unpack(); + this._playbackStatusChangePending = status !== 'Paused'; + this._playerProxyId = this._playerProxy.connect( + 'g-properties-changed', () => this._playerPropsChanged()); + } else { + try { + await this._playerProxy.call( + 'Play', + null, + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (e) { + this._log.error(`Error call Mpris Play method. Error: ${e}`); + } + } + } + } + + _onPlayerProxyReady() { + const status = this._playerProxy?.get_cached_property('PlaybackStatus')?.unpack(); + if (this._requestedState === 'play' && status === 'Playing') { + this._lastPausedPlayer = null; + this._mprisNames = []; + } else if (this._requestedState === 'play' && status === 'Paused') { + this._lastPausedPlayer = null; + this._mprisNames = []; + this._changeStatus(); + } else if (this._requestedState === 'pause' && status === 'Playing') { + this._mprisNames = []; + this._lastPausedPlayer = this._busname; + this._changeStatus(); + } else { + this._playerProxy = null; + this._iteratePlayers(); + } + } + + async _initPlayerProxy(busname) { + try { + this._playerProxy = await Gio.DBusProxy.new_for_bus( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + null, + busname, + '/org/mpris/MediaPlayer2', + 'org.mpris.MediaPlayer2.Player', + null + ); + } catch (e) { + this._log.error(`Failed to initialize proxy in player proxy. Error: ${e}`); + return; + } + this._onPlayerProxyReady(); + } + + _iteratePlayers() { + if (this._mprisNames.length === 0) + return; + + this._busname = this._mprisNames.shift(); + this._initPlayerProxy(this._busname); + } + + _disconnectPlayerProxy() { + if (this._playerProxyId) + this._playerProxy?.disconnect(this._playerProxyId); + this._playerProxy = null; + this._playerProxyId = null; + } + + async changeActivePlayerState(requestedState) { + if (!this._controllerReady || !this._sink) + return; + + if (requestedState === 'pause' && !this._sinkStateIsRunning) + return; + + this._requestedState = requestedState; + this._disconnectPlayerProxy(); + + let names = []; + try { + const res = await Gio.DBus.session.call( + 'org.freedesktop.DBus', + '/org/freedesktop/DBus', + 'org.freedesktop.DBus', + 'ListNames', + null, + new GLib.VariantType('(as)'), + Gio.DBusCallFlags.NONE, + -1, + null + ); + + if (res) + [names] = res.deepUnpack(); + } catch (e) { + this._log.error(`Bluetooth-Battery-Meter: Error calling ListNames. Error: ${e}`); + return; + } + + this._mprisNames = names.filter(name => name.startsWith(MEDIA_PLAYER_PREFIX)); + if (this._requestedState === 'play') { + if (this._lastPausedPlayer && this._mprisNames.includes(this._lastPausedPlayer)) + this._initPlayerProxy(this._lastPausedPlayer); + } else { + this._iteratePlayers(); + } + } + + _onDestroy() { + if (this._previousVolume > -1) { + const lastAttenuationInfo = { + path: this._devicePath, + timestamp: Date.now(), + volume: this._previousVolume, + }; + this._settings.set_strv('attenuated-on-destroy-info', + [JSON.stringify(lastAttenuationInfo)]); + } + } + + destroy() { + if (this._volumeRampTimeoutId) + GLib.source_remove(this._volumeRampTimeoutId); + this._volumeRampTimeoutId = null; + this._onDestroy(); + this._disconnectController(); + this._disconnectPlayerProxy(); + if (this._stateChangeId) + this._control?.disconnect(this._stateChangeId); + this._stateChangeId = null; + this._controllerReady = false; + this._lastPausedPlayer = null; + this._playbackStatusChangePending = null; + this._control = null; + } +}); + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/BudsPro.js b/bbm/lib/devices/nothingBuds/deviceConfigs/BudsPro.js new file mode 100644 index 0000000000..9d8c1850c2 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/BudsPro.js @@ -0,0 +1,109 @@ +'use strict'; + +export default { + modelId: 'B163', + name: 'Buds Pro', + pattern: /^.*Buds Pro$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + dualConnection: false, + dualConnectionReboot: false, + + gestureOptions: { + default: '080201020902010308020107160201090103010209030103080301071603010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-round', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds.js new file mode 100644 index 0000000000..2814929906 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds.js @@ -0,0 +1,111 @@ +'use strict'; + +export default { + modelId: 'B168', + name: 'CMF Buds', + pattern: /^.*CMF Buds$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + dirac: 0x00, + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + noiseCancellation: {byte: 0x04}, + transparency: {byte: 0x07}, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + dualConnection: true, + dualConnectionReboot: true, + + gestureOptions: { + default: '080201020902010308020107160201090103010209030103080301071603010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2.js new file mode 100644 index 0000000000..4a4eb9f506 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2.js @@ -0,0 +1,125 @@ +'use strict'; + +export default { + modelId: 'B179', + name: 'CMF Buds 2', + pattern: /^.*CMF Buds 2$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + dirac: 0x00, + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: false, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + spatialAudioSwitch: true, + + gestureOptions: { + default: '080301020903010308030107160301090102010209020103080201071602010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'no-action': [0x01], + 'news-description': [0x1F], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2A.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2A.js new file mode 100644 index 0000000000..6a9e5ffeae --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2A.js @@ -0,0 +1,116 @@ +'use strict'; + +export default { + modelId: 'B185', + name: 'CMF Buds 2a', + pattern: /^.*CMF Buds 2a$/, + + + batteryLR: true, + batteryCase: true, + + eqPreset: { + dirac: 0x07, + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + noiseCancellation: {byte: 0x04}, + transparency: {byte: 0x07}, + }, + + lowLatencyMode: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + + gestureOptions: { + default: '080201020902010308020107160201090103010209030103080301071603010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'news-description': [0x01F], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'news-description', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2Plus.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2Plus.js new file mode 100644 index 0000000000..b98fc1f41a --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2Plus.js @@ -0,0 +1,119 @@ +'use strict'; + +export default { + modelId: 'B184', + name: 'CMF Buds 2 Plus', + pattern: /^.*CMF Buds 2 Plus$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + spatialAudioSwitch: true, + + gestureOptions: { + default: '080301020903010308030107160301090102010209020103080201071602010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudsPro2.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudsPro2.js new file mode 100644 index 0000000000..f9f98fbe11 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudsPro2.js @@ -0,0 +1,193 @@ +'use strict'; +/* eslint-disable max-len */ +export default { + modelId: 'B172', + name: 'CMF Buds Pro 2', + pattern: /^.*CMF Buds Pro 2$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + dirac: 0x00, + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: true, + spatialAudioSwitch: true, + + gestureOptions: { + default: '0f03010209030103080301071603010901020102090201030802010716020109010401010104010201040103010401070104010a010409020104090301', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-single'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-double'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-triple'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-double-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-rotate'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + 'case-knob-single': 0x01, + 'case-knob-double': 0x02, + 'case-knob-triple': 0x03, + 'case-knob-action-hold': 0x07, + 'case-knob-double-action-hold': 0x09, + 'case-knob-rotate': 0x0A, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'case-game-mode': [0x11], + 'volume-control': [0x17], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + 'case-knob-single': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-double': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-triple': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-double-action-hold': { + type: 'press', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-rotate': { + type: 'rotate', + actions: [ + 'volume-control', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudspro2.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudspro2.js new file mode 100644 index 0000000000..a171d4b0b1 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudspro2.js @@ -0,0 +1,193 @@ +'use strict'; +/* eslint-disable max-len */ +export default { + modelId: 'B187', + name: 'CMF Buds Pro 2', + pattern: /^.*CMF Buds Pro 2$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + dirac: 0x00, + pop: 0x03, + rock: 0x01, + electronic: 0x02, + enhance_vocals: 0x04, + classical: 0x05, + custom: 0x06, + }, + eqListeningModeType: true, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: true, + spatialAudioSwitch: true, + + gestureOptions: { + default: '0f03010209030103080301071603010901020102090201030802010716020109010401010104010201040103010401070104010a010409020104090301', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-single'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-double'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-triple'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-double-action-hold'}, + {group: 'case-knob', device: 0x04, buttonId: 0x01, type: 'case-knob-rotate'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + 'case-knob-single': 0x01, + 'case-knob-double': 0x02, + 'case-knob-triple': 0x03, + 'case-knob-action-hold': 0x07, + 'case-knob-double-action-hold': 0x09, + 'case-knob-rotate': 0x0A, + }, + actions: { + 'no-action': [0x01], + 'play-pause': [0x02], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'case-game-mode': [0x11], + 'volume-control': [0x17], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + 'voice-assistant', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'tap', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + 'case-knob-single': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-double': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-triple': { + type: 'press', + actions: [ + 'play-pause', + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-double-action-hold': { + type: 'press', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'case-game-mode', + 'no-action', + ], + }, + 'case-knob-rotate': { + type: 'rotate', + actions: [ + 'volume-control', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/CMFHeadphonePro.js b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFHeadphonePro.js new file mode 100644 index 0000000000..f3bd6fad41 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/CMFHeadphonePro.js @@ -0,0 +1,117 @@ +'use strict'; + +// Nothing Headphone (1) +export default { + modelId: 'B175', + name: 'CMF Headphone Pro', + pattern: /^.*CMF Headphone Pro$/, + + batterySingle: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + spatialimmersiveModes: true, + + gestureOptions: { + default: '060A0107060A010B0605012306010716', + slots: [ + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'single'}, + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'action-hold'}, + {group: 'roller', device: 0x06, buttonId: 0x01, type: 'roller-action-hold'}, + {group: 'slider', device: 0x06, buttonId: 0x05, type: 'slider-single'}, + ], + mapping: { + gestureTypes: { + 'single': 0x01, + 'action-hold': 0x07, + 'roller-action-hold': 0x07, + 'slider-single': 0x01, + }, + actions: { + 'no-action': [0x01], + 'channel-hop': [0x20], + 'voice-assistant': [0x0B], + 'news-description': [0x1F], + 'spatial-audio': [0x1B], + 'mic-on-off': [0x1D], + 'eq-preset': [0x22], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'ultra-bass': [0x23], + 'treble-enhance': [0x24], + }, + }, + gestures: { + 'single': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'spatial-audio', + 'mic-on-off', + 'eq-preset', + 'noise-control', + 'no-action', + ], + }, + 'action-hold': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'spatial-audio', + 'mic-on-off', + 'eq-preset', + 'noise-control', + 'no-action', + ], + }, + 'roller-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'no-action', + ], + }, + 'slider-single': { + type: 'slide', + actions: [ + 'ultra-bass', + 'treble-enhance', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/Ear2.js b/bbm/lib/devices/nothingBuds/deviceConfigs/Ear2.js new file mode 100644 index 0000000000..f1c8a2251a --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/Ear2.js @@ -0,0 +1,118 @@ +'use strict'; + +export default { + modelId: 'B155', + name: 'Ear (2)', + pattern: /^.*Ear \(2\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + personalizeAnc: true, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: true, + + gestureOptions: { + default: '080201020903010209020103080301030802010716030107160201090103010901', + + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + + {group: 'right', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + ], + + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/EarStick.js b/bbm/lib/devices/nothingBuds/deviceConfigs/EarStick.js new file mode 100644 index 0000000000..d84aedcedc --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/EarStick.js @@ -0,0 +1,103 @@ +'use strict'; + +export default { + modelId: 'B157', + name: 'Ear (Stick)', + pattern: /^.*Ear \(Stick\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + noiseControl: { + off: {byte: 0x01}, + noiseCancellation: {byte: 0x04}, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + dualConnection: false, + + gestureOptions: { + default: '08020102090301020902010308030103080201070A0301070A0201090103010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NeckbandPro.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NeckbandPro.js new file mode 100644 index 0000000000..46e3a711a1 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NeckbandPro.js @@ -0,0 +1,90 @@ +'use strict'; + +export default { + modelId: 'B164', + name: 'Neckband Pro', + pattern: /^.*Neckband Pro$/, + + batterySingle: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + lowLatencyMode: true, + ring: true, + dualConnection: true, + dualConnectionReboot: true, + spatialAudioSwitch: true, + + gestureOptions: { + default: '03060102090601030806010716', + slots: [ + {group: 'single', device: 0x06, buttonId: 0x01, type: 'double'}, + {group: 'single', device: 0x06, buttonId: 0x01, type: 'triple'}, + {group: 'single', device: 0x06, buttonId: 0x01, type: 'action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'double': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'noise-control', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + + albumArtIcon: 'earbuds-neckband', + budsIcon: 'earbuds-neckband', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar.js new file mode 100644 index 0000000000..fbf7fab0e1 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar.js @@ -0,0 +1,117 @@ +'use strict'; + +export default { + modelId: 'B171', + name: 'Nothing Ear', + pattern: /^.*Nothing Ear$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + personalizeAnc: true, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + + gestureOptions: { + default: '080201020903010209020103080301030802010716030107160201090103010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar1.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar1.js new file mode 100644 index 0000000000..9973f55756 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar1.js @@ -0,0 +1,83 @@ +'use strict'; + +export default { + modelId: 'B181', + name: 'Nothing ear (1)', + pattern: /^.*Nothing ear \(1\)$/, + + noUTCTimeUpdate: true, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + }, + + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + high: 0x01, + }, + }, + }, + + inEarDetection: false, + lowLatencyMode: true, + ring: true, + ringLegacy: true, + dualConnection: false, + + gestureOptions: { + default: '06020102080301020802010308030103080201070A0301070A', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + ], + mapping: { + gestureTypes: { + 'triple': 0x03, + 'action-hold': 0x07, + }, + actions: { + 'no-action': [0x01], + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'anc-type': [0x0A], + }, + }, + gestures: { + 'triple': { + type: 'tap', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'tap', + actions: [ + 'anc-type', + 'no-action', + ], + }, + }, + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar3.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar3.js new file mode 100644 index 0000000000..46f3c1868d --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar3.js @@ -0,0 +1,122 @@ +'use strict'; + +// Nothing Ear (2) +export default { + modelId: 'B173', + name: 'Nothing Ear (3)', + pattern: /^.*Nothing Ear \(3\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + personalizeAnc: true, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + spatialAudioSwitch: true, + + gestureOptions: { + default: '0902010209030102090201030803010308020107160301071602010901030109010401070B', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-forward': [0x09], + 'skip-back': [0x08], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'mic-on-off': [0x1D], + 'essential-space': [0x21], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'press', + actions: [ + 'skip-forward', + 'skip-back', + 'voice-assistant', + ], + }, + 'triple': { + type: 'press', + actions: [ + 'skip-forward', + 'skip-back', + 'voice-assistant', + 'no-action', + ], + }, + 'action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'voice-assistant', + 'volume-up', + 'volume-down', + 'mic-on-off', + 'no-action', + ], + }, + 'double-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'voice-assistant', + 'volume-up', + 'volume-down', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarA.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarA.js new file mode 100644 index 0000000000..634eebd80d --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarA.js @@ -0,0 +1,114 @@ +'use strict'; + +export default { + modelId: 'B162', + name: 'Nothing Ear (a)', + pattern: /^.*Nothing Ear \(a\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + + gestureOptions: { + default: '080201020903010209020103080301030802010716030107160201090103010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarOpen.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarOpen.js new file mode 100644 index 0000000000..4418e33cc1 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarOpen.js @@ -0,0 +1,90 @@ +'use strict'; + +export default { + modelId: 'B174', + name: 'Nothing Ear (open)', + pattern: /^.*Nothing Ear \(open\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + lowLatencyMode: true, + + gestureOptions: { + default: '080201020903010209020103080301030802010713030107120201090103010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + }, + + albumArtIcon: 'earbuds-wingtip2', + budsIcon: 'earbuds-wingtip2', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEara.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEara.js new file mode 100644 index 0000000000..5345b44fbf --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingEara.js @@ -0,0 +1,114 @@ +'use strict'; + +export default { + modelId: 'B183', + name: 'Nothing Ear (a)', + pattern: /^.*Nothing Ear \(a\)$/, + + batteryLR: true, + batteryCase: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + earTipTest: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + + gestureOptions: { + default: '080201020903010209020103080301030802010716030107160201090103010901', + slots: [ + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'triple'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'action-hold'}, + {group: 'left', device: 0x02, buttonId: 0x01, type: 'double-action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'triple'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'action-hold'}, + {group: 'right', device: 0x03, buttonId: 0x01, type: 'double-action-hold'}, + ], + mapping: { + gestureTypes: { + 'double': 0x02, + 'triple': 0x03, + 'action-hold': 0x07, + 'double-action-hold': 0x09, + }, + actions: { + 'skip-back': [0x08], + 'skip-forward': [0x09], + 'voice-assistant': [0x0B], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'volume-up': [0x12], + 'volume-down': [0x13], + 'no-action': [0x01], + }, + }, + gestures: { + 'double': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'triple': { + type: 'pinch', + actions: [ + 'skip-back', + 'skip-forward', + 'voice-assistant', + ], + }, + 'action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + ], + }, + 'double-action-hold': { + type: 'pinch', + actions: [ + 'noise-control', + 'volume-up', + 'volume-down', + 'voice-assistant', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-square', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphone1.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphone1.js new file mode 100644 index 0000000000..28b5415580 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphone1.js @@ -0,0 +1,106 @@ +'use strict'; + +export default { + modelId: 'B170', + name: 'Nothing Headphone (1)', + pattern: /^.*Nothing Headphone \(1\)$/, + + batterySingle: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + bassEnhanceLevel: 5, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + dualConnection: true, + dualConnectionReboot: false, + spatialHeadTracking: true, + + gestureOptions: { + default: '0306010716060A010B060A0701', + slots: [ + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'single'}, + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'action-hold'}, + {group: 'roller', device: 0x06, buttonId: 0x01, type: 'roller-action-hold'}, + ], + mapping: { + gestureTypes: { + 'single': 0x01, + 'action-hold': 0x07, + 'roller-action-hold': 0x07, + }, + actions: { + 'no-action': [0x01], + 'channel-hop': [0x20], + 'voice-assistant': [0x0B], + 'news-description': [0x1F], + 'spatial-audio': [0x1B], + 'mic-on-off': [0x1D], + 'eq-preset': [0x22], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + }, + }, + gestures: { + 'single': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'spatial-audio', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'action-hold': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'spatial-audio', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'roller-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphoneA.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphoneA.js new file mode 100644 index 0000000000..bcb6eb03cb --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphoneA.js @@ -0,0 +1,105 @@ +'use strict'; + +export default { + modelId: 'B186', + name: 'Nothing Headphone (a)', + pattern: /^.*Nothing Headphone \(a\)$/, + + batterySingle: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + bassEnhanceLevel: 3, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + lowLatencyMode: true, + ring: true, + dualConnection: true, + spatialHeadTracking: true, + autoPowerOff: [30, 60, 120, 180, 240], + + gestureOptions: { + default: '0306010716060A010B060A0701', + slots: [ + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'single'}, + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'action-hold'}, + {group: 'roller', device: 0x06, buttonId: 0x01, type: 'roller-action-hold'}, + ], + mapping: { + gestureTypes: { + 'single': 0x01, + 'action-hold': 0x07, + 'roller-action-hold': 0x07, + }, + actions: { + 'no-action': [0x01], + 'channel-hop': [0x20], + 'voice-assistant': [0x0B], + 'news-description': [0x1F], + 'spatial-audio': [0x1B], + 'mic-on-off': [0x1D], + 'eq-preset': [0x22], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'take-photo': [0x18], + }, + }, + gestures: { + 'single': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'take-photo', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'action-hold': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'roller-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphonea.js b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphonea.js new file mode 100644 index 0000000000..12a01c6938 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphonea.js @@ -0,0 +1,105 @@ +'use strict'; + +export default { + modelId: 'B198', + name: 'Nothing Headphone (a)', + pattern: /^.*Nothing Headphone \(a\)$/, + + batterySingle: true, + + eqPreset: { + balanced: 0x00, + voice: 0x01, + more_treble: 0x02, + more_bass: 0x03, + custom: 0x05, + advance: 0x06, + }, + + bassEnhanceLevel: 3, + + noiseControl: { + off: {byte: 0x05}, + transparency: {byte: 0x07}, + noiseCancellation: { + levels: { + low: 0x03, + mid: 0x02, + high: 0x01, + adaptive: 0x04, + }, + }, + }, + + lowLatencyMode: true, + ring: true, + dualConnection: true, + spatialHeadTracking: true, + autoPowerOff: [30, 60, 120, 180, 240], + + gestureOptions: { + default: '0306010716060A010B060A0701', + slots: [ + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'single'}, + {group: 'single', device: 0x06, buttonId: 0x0A, type: 'action-hold'}, + {group: 'roller', device: 0x06, buttonId: 0x01, type: 'roller-action-hold'}, + ], + mapping: { + gestureTypes: { + 'single': 0x01, + 'action-hold': 0x07, + 'roller-action-hold': 0x07, + }, + actions: { + 'no-action': [0x01], + 'channel-hop': [0x20], + 'voice-assistant': [0x0B], + 'news-description': [0x1F], + 'spatial-audio': [0x1B], + 'mic-on-off': [0x1D], + 'eq-preset': [0x22], + 'noise-control': [0x0A, 0x14, 0x15, 0x16], + 'take-photo': [0x18], + }, + }, + gestures: { + 'single': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'take-photo', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'action-hold': { + type: 'press', + actions: [ + 'channel-hop', + 'voice-assistant', + 'news-description', + 'noise-control', + 'mic-on-off', + 'eq-preset', + 'no-action', + ], + }, + 'roller-action-hold': { + type: 'press', + actions: [ + 'noise-control', + 'no-action', + ], + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/nothingBuds/nothingBudsConfig.js b/bbm/lib/devices/nothingBuds/nothingBudsConfig.js new file mode 100644 index 0000000000..01b8acb92b --- /dev/null +++ b/bbm/lib/devices/nothingBuds/nothingBudsConfig.js @@ -0,0 +1,172 @@ +'use strict'; + +import CMFBuds2Plus from './deviceConfigs/CMFBuds2Plus.js'; +import CMFBudsPro2 from './deviceConfigs/CMFBudsPro2.js'; +import CMFBudspro2 from './deviceConfigs/CMFBudspro2.js'; +import CMFBuds2A from './deviceConfigs/CMFBuds2A.js'; +import CMFBuds2 from './deviceConfigs/CMFBuds2.js'; +import BudsPro from './deviceConfigs/BudsPro.js'; +import CMFBuds from './deviceConfigs/CMFBuds.js'; +import CMFHeadphonePro from './deviceConfigs/CMFHeadphonePro.js'; +import NeckbandPro from './deviceConfigs/NeckbandPro.js'; +import NothingHeadphone1 from './deviceConfigs/NothingHeadphone1.js'; +import NothingEarA from './deviceConfigs/NothingEarA.js'; +import NothingEara from './deviceConfigs/NothingEara.js'; +import NothingEarOpen from './deviceConfigs/NothingEarOpen.js'; +import NothingEar3 from './deviceConfigs/NothingEar3.js'; +import Ear2 from './deviceConfigs/Ear2.js'; +import NothingEar1 from './deviceConfigs/NothingEar1.js'; +import EarStick from './deviceConfigs/EarStick.js'; +import NothingEar from './deviceConfigs/NothingEar.js'; +import NothingHeadphonea from './deviceConfigs/NothingHeadphonea.js'; +import NothingHeadphoneA from './deviceConfigs/NothingHeadphoneA.js'; + +export const NothingBudsModelList = [ + CMFBuds2Plus, + CMFBudsPro2, + CMFBudspro2, + CMFBuds2A, + CMFBuds2, + BudsPro, + CMFBuds, + CMFHeadphonePro, + NeckbandPro, + NothingHeadphone1, + NothingEarA, + NothingEara, + NothingEarOpen, + NothingEar3, + Ear2, + NothingEar1, + EarStick, + NothingEar, + NothingHeadphonea, + NothingHeadphoneA, +]; + +export const PayloadType = { + PROTOCOL_GET: 0xC001, + PROTOCOL_SET: 0xF001, + + SERIAL_GET: 0xC006, + SERIAL_RET: 0x4006, + + FIRMWARE_GET: 0xC042, + FIRMWARE_NTFY: 0x4042, + + DEVICE_MODEL_GET: 0xC01C, + DEVICE_MODEL_RET: 0x401C, + + UTC_TIME_SET: 0xF00A, + + BATTERY_GET: 0xC007, + BATTERY_PRIMARY_NTFY: 0xE001, + BATTERY_SECONDARY_NTFY: 0x4007, + + LED_CASE_COLORS_GET: 0xC017, + LED_CASE_COLORS_SET: 0xF00D, + LED_CASE_COLORS_NTFY: 0x4017, + + GESTURES_GET: 0xC018, + GESTURES_SET: 0xF003, + GESTURES_NTFY: 0x4018, + + ANC_CONFIGURATION_GET: 0xC01D, + ANC_GET: 0xC01E, + ANC_SET: 0xF00F, + ANC_PRIMARY_NTFY: 0xE003, + ANC_SECONDARY_NTFY: 0x401E, + + PERSONALIZED_ANC_GET: 0xC020, + PERSONALIZED_ANC_SET: 0xF011, + PERSONALIZED_ANC_NTFY: 0x4020, + + EQ_GET: 0xC01F, + EQ_SET: 0xF010, + EQ_NTFY: 0x401F, + + ADVANCED_EQ_GET: 0xC04C, + ADVANCED_EQ_ENABLED_SET: 0xF04F, + ADVANCED_EQ_NTFY: 0x404C, + + CUSTOM_EQ_GET: 0xC044, + CUSTOM_EQ_SET: 0xF041, + CUSTOM_EQ_NTFY: 0x4044, + + ENHANCED_BASS_GET: 0xC04E, + ENHANCED_BASS_SET: 0xF051, + ENHANCED_BASS_RET: 0x404E, + ENHANCED_BASS_NTFY: 0x7051, + + LISTENING_MODE_GET: 0xC050, + LISTENING_MODE_SET: 0xF01D, + LISTENING_MODE_NTFY: 0x4050, + + SPATIAL_AUDIO_GET: 0xC04F, + SPATIAL_AUDIO_SET: 0xF052, + SPATIAL_AUDIO_NTFY: 0x404F, + + IN_EAR_GET: 0xC00E, + IN_EAR_SET: 0xF004, + IN_EAR_NTFY: 0x400E, + + LATENCY_GET: 0xC041, + LATENCY_SET: 0xF040, + LATENCY_RET: 0x4041, + LATENCY_NTFY: 0x7040, + + RING_SET: 0xF002, + EAR_FIT_TEST_START: 0xF014, + EAR_FIT_TEST_NTFY: 0xE00D, +}; + +const crc16AnsiTab = Uint16Array.from([ + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040, +]); + +export function crc16Ansi(buffer) { + let crc = 0xFFFF; + for (const b of buffer) + crc = crc16AnsiTab[(crc ^ b) & 0xFF] ^ crc >> 8; + + return crc & 0xFFFF; +} + +export const BatteryType = { + LEFT: 0x02, + RIGHT: 0x03, + CASE: 0x04, + SINGLE: 0x06, +}; + diff --git a/bbm/lib/devices/nothingBuds/nothingBudsDevice.js b/bbm/lib/devices/nothingBuds/nothingBudsDevice.js new file mode 100644 index 0000000000..0d54d6fb13 --- /dev/null +++ b/bbm/lib/devices/nothingBuds/nothingBudsDevice.js @@ -0,0 +1,856 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, isArrayEqual +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {NothingBudsSocket} from './nothingBudsSocket.js'; + +export const DeviceTypeNothingBuds = 'nothingBuds'; + +const NothingBudsUUID = 'aeac4a03-dff5-498f-843a-34487cf133eb'; +export function isNothingBuds(bluezDeviceProxy, uuids) { + const bluezProps = []; + const supported = uuids.includes(NothingBudsUUID) ? 'yes' : 'no'; + return {supported, bluezProps}; +} + +export const NothingBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_NothingBudsDevice', +}, class NothingBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `NothingBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- NothingBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._ignoreGsettingsChange = false; + this._fwVersion = ''; + this._serialNo = ''; + + this._config = createConfig(); + this._props = createProperties(); + this._modelData = null; + + this._callbacks = { + modelIntialized: this.modelIntialized.bind(this), + updateFirmwareInfo: this.updateFirmwareInfo.bind(this), + updateSerial: this.updateSerial.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateNoiseControl: this.updateNoiseControl.bind(this), + updatePersonalizedAnc: this.updatePersonalizedAnc.bind(this), + updateEqPreset: this.updateEqPreset.bind(this), + updateCustomEq: this.updateCustomEq.bind(this), + updateEnhancedBass: this.updateEnhancedBass.bind(this), + updateLatency: this.updateLatency.bind(this), + updateInEar: this.updateInEar.bind(this), + updateSpatialAudio: this.updateSpatialAudio.bind(this), + updateGestures: this.updateGestures.bind(this), + }; + + const profile = {type: DeviceTypeNothingBuds, uuid: NothingBudsUUID}; + + this._nothingBudsSocket = new NothingBudsSocket( + this._devicePath, + profileManager, + profile, + this._callbacks + ); + } + + modelIntialized(modelData) { + this._modelData = modelData; + + this._log.info(`Configuration: ${JSON.stringify(this._modelData, null, 2)}`); + + this._commonIcon = this._modelData.budsIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.showSettingsButton = true; + + if (this._modelData.batteryCase) + this._caseIcon = `${this._modelData.case}`; + + this._createDefaultSettings(); + + const devicesList = this._settings.get_strv('nothing-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'nothing-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorNothingBudsListGsettings(); + this._updateIcons(); + this._updateAncConfig(); + + if (this._modelData.ring) { + this._ringState = 'stopped'; + this._settingsItems['ring-state'] = this._ringState; + if (!this._modelData.ringLegacy && !this._modelData.batterySingle) { + this._ringStateLeft = 'stopped'; + this._settingsItems['ring-state-left'] = this._ringStateLeft; + } + this._updateGsettings(); + } + } + + _createDefaultSettings() { + this._defaultsDeviceSettings = { + path: this._devicePath, + modelid: this._modelData.modelId, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': this._fwVersion, + 'serial': this._serialNo, + + ...this._modelData.batteryCase && { + 'case': this._caseIcon, + }, + + ...this._modelData.eqPreset && { + 'eq-preset': Object.values(this._modelData.eqPreset)[0], + }, + + ...this._modelData.eqPreset?.custom !== undefined && { + 'eq-custom': [0, 0, 0], + }, + + ...this._modelData.bassEnhanceLevel && { + 'bass-enable': false, + 'bass-level': 1, + }, + + ...this._modelData.lowLatencyMode && { + 'lowlatency': false, + }, + + ...this._modelData.inEarDetection && { + 'inear-enable': false, + }, + + ...this._modelData.spatialAudioSwitch && { + 'spatial': false, + }, + + ...this._modelData.ring && { + 'ring-state': 'stopped', + ...!this._modelData.ringLegacy && !this._modelData.batterySingle && { + 'ring-state-left': 'stopped', + }, + }, + + ...this._modelData.gestureOptions && { + 'gestures': this._createDefaultGestures(), + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('nothing-buds-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('nothing-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._modelData.batteryCase) + this._caseIcon = this._settingsItems['case']; + + if (this._modelData.eqPreset) + this._eqPreset = this._settingsItems['eq-preset']; + + if (this._modelData.eqPreset?.custom !== undefined) + this._customEq = this._settingsItems['eq-custom']; + + if (this._modelData.bassEnhanceLevel) { + this._bassEnabled = this._settingsItems['bass-enable']; + this._bassLevel = this._settingsItems['bass-level']; + } + + if (this._modelData.lowLatencyMode) + this._lowlatency = this._settingsItems['lowlatency']; + + if (this._modelData.inEarDetection) + this._inEar = this._settingsItems['inear-enable']; + + if (this._modelData.spatialAudioSwitch) + this._spatialAudio = this._settingsItems['spatial']; + + if (this._modelData.ring) { + this._ringState = 'stopped'; + if (!this._modelData.ringLegacy && !this._modelData.batterySingle) + this._ringStateLeft = 'stopped'; + } + + if (this._modelData.gestureOptions) + this._gestures = this._settingsItems['gestures']; + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('nothing-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._modelData.batteryCase) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + if (this._modelData.eqPreset) { + const eqPreset = this._settingsItems['eq-preset']; + if (this._eqPreset !== eqPreset) { + this._eqPreset = eqPreset; + this._setEqPreset(eqPreset); + } + } + + if (this._modelData.eqPreset?.custom !== undefined) { + const eqCustom = this._settingsItems['eq-custom']; + + if (!this._customEq || !isArrayEqual(eqCustom, this._customEq)) { + this._customEq = eqCustom; + this._setCustomEq(eqCustom); + } + } + + if (this._modelData.bassEnhanceLevel) { + const bassEnabled = this._settingsItems['bass-enable']; + const bassLevel = this._settingsItems['bass-level']; + + if (this._bassEnabled !== bassEnabled || this._bassLevel !== bassLevel) { + this._bassEnabled = bassEnabled; + this._bassLevel = bassLevel; + this._setEnhancedBass(bassEnabled, bassLevel); + } + } + + if (this._modelData.lowLatencyMode) { + const enable = this._settingsItems['lowlatency']; + + if (this._lowlatency !== enable) { + this._lowlatency = enable; + this._setLatency(enable); + } + } + + if (this._modelData.inEarDetection) { + const enable = this._settingsItems['inear-enable']; + + if (this._inEar !== enable) { + this._inEar = enable; + this._setInEar(enable); + } + } + + if (this._modelData.spatialAudioSwitch) { + const enable = this._settingsItems['spatial']; + + if (this._spatialAudio !== enable) { + this._spatialAudio = enable; + this._setSpatialAudio(enable); + } + } + + if (this._modelData.ring) { + const state = this._settingsItems['ring-state']; + if (this._ringState !== state) { + this._ringState = state; + this._setRingMyBuds(state); + } + + if (!this._modelData.ringLegacy && !this._modelData.batterySingle) { + const stateLeft = this._settingsItems['ring-state-left']; + if (this._ringStateLeft !== stateLeft) { + this._ringStateLeft = stateLeft; + this._setRingMyBuds(stateLeft, true); + } + } + } + + if (this._modelData.gestureOptions) { + const newGestures = this._settingsItems['gestures']; + + if (!this._gestureSlotsEqual(this._gestures, newGestures)) { + const oldGestures = this._gestures; + this._gestures = newGestures; + + const diff = this._findGestureDiff(newGestures, oldGestures); + if (diff) + this._setGesture(diff); + } + } + } + + _monitorNothingBudsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::nothing-buds-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('nothing-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('nothing-buds-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1ShowOnDisconnect = true; + if (this._modelData.batteryLR) { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery2ShowOnDisconnect = true; + this._config.battery3Icon = this._caseIcon; + } else { + this._config.battery1Icon = this._commonIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + _updateAncConfig() { + const nc = this._modelData.noiseControl; + if (!nc) + return; + + let buttonIndex = 1; + this._ancToggleMap = {}; + this._config.toggle1Title = _('Noise Control'); + + const addToggle = (type, bytes, icon, name) => { + this._config[`toggle1Button${buttonIndex}Icon`] = icon; + this._config[`toggle1Button${buttonIndex}Name`] = name; + this._ancToggleMap[buttonIndex] = {type, bytes}; + buttonIndex++; + }; + + if (nc.off) + addToggle('off', [nc.off.byte], 'bbm-anc-off-symbolic', _('Off')); + + if (nc.transparency) { + addToggle('transparency', [nc.transparency.byte], + 'bbm-transperancy-symbolic', _('Transparency')); + } + + let hasNcLevel = false; + if (nc.noiseCancellation) { + let bytes = []; + this._ancRadioMap = {}; + this._ancRadioReverse = {}; + + if (nc.noiseCancellation.levels) { + hasNcLevel = true; + const levelOrder = ['low', 'mid', 'high', 'adaptive']; + const levelsObj = nc.noiseCancellation.levels; + const validLevels = levelOrder.filter(l => l in levelsObj); + const labels = []; + + this._config.box1RadioTitle = _('Noise Cancellation Level'); + + const levelNames = { + low: _('Low'), + mid: _('Mid'), + high: _('High'), + adaptive: _('Adaptive'), + }; + + validLevels.forEach((level, i) => { + const index = i + 1; + labels.push(levelNames[level]); + const byte = levelsObj[level]; + bytes.push(byte); + this._ancRadioMap[index] = byte; + this._ancRadioReverse[byte] = index; + }); + + this._log.info(`ANC level labels: ${JSON.stringify(labels)}`); + this._log.info(`ANC level bytes: ${JSON.stringify(bytes)}`); + + + this._config.box1RadioButton = validLevels.map(l => levelNames[l]); + } else if (nc.noiseCancellation.byte != null) { + bytes = [nc.noiseCancellation.byte]; + } + + addToggle('noiseCancellation', bytes, + 'bbm-anc-on-symbolic', _('Noise Cancellation')); + } + + if (hasNcLevel) + this._config.optionsBox1.push('radio-button'); + + if (this._modelData.personalizeAnc) { + this._config.optionsBox1.push('check-button'); + this._config.box1CheckButton = [_('Personalised ANC')]; + } + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + if (this._modelData.noiseControl) + this._props.toggle1Visible = true; + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'box1CheckButton1State') + this._box1CheckButton1StateChanged(value); + + if (command === 'box1RadioButtonState') + this._box1RadioButtonStateChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateFirmwareInfo(fwVersion) { + this._fwVersion = fwVersion; + if (this._settingsItems) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + } + + updateSerial(serial) { + this._serialNo = serial; + if (this._settingsItems) { + this._settingsItems['serial'] = serial; + this._updateGsettings(); + } + } + + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + if (!this._modelData.batteryLR) + this._props.computedBatteryLevel = props.battery1Level; + else + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + + if (!this._battInfoRecieved) + this._startConfiguration(props); + + this.dataHandler?.setProps(this._props); + } + + updateNoiseControl(mode) { + if (!this._ancToggleMap) + return; + + const nc = this._modelData.noiseControl; + let toggleIndex = 0; + let isNcMode = false; + + for (const [index, {bytes}] of Object.entries(this._ancToggleMap)) { + if (bytes.includes(mode)) { + toggleIndex = Number(index); + if (this._ancToggleMap[toggleIndex]?.type === 'noiseCancellation') + isNcMode = true; + break; + } + } + + this._props.toggle1State = toggleIndex; + + if (isNcMode && this._ancRadioReverse && this._ancRadioReverse[mode]) + this._props.box1RadioButtonState = this._ancRadioReverse[mode]; + + if (isNcMode && (nc?.noiseCancellation?.levels || this._modelData.personalizeAnc)) + this._props.optionsBoxVisible = 1; + else + this._props.optionsBoxVisible = 0; + + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + if (!this._ancToggleMap) + return; + + let ancMode = null; + const toggle = this._ancToggleMap[index]; + if (!toggle) + return; + + this._props.toggle1State = index; + + if (toggle.type === 'noiseCancellation') { + const boxVisible = toggle.bytes.length > 1 || this._modelData.personalizeAnc; + this._props.optionsBoxVisible = boxVisible ? 1 : 0; + + if (toggle.bytes.length > 1) { + const radioIndex = this._props.box1RadioButtonState; + let byte = this._ancRadioMap[radioIndex]; + if (byte == null) { + const levels = this._modelData.noiseControl.noiseCancellation.levels; + const lastLevelKey = Object.keys(levels).pop(); + byte = levels[lastLevelKey]; + this._props.box1RadioButtonState = this._ancRadioReverse[byte]; + } + ancMode = byte; + } else { + ancMode = toggle.bytes[0]; + } + } else { + this._props.optionsBoxVisible = 0; + ancMode = toggle.bytes[0]; + } + + this.dataHandler?.setProps(this._props); + + if (ancMode != null) + this._nothingBudsSocket?.setNoiseControl(ancMode); + } + + _box1RadioButtonStateChanged(index) { + if (!this._ancRadioMap) + return; + + this._props.box1RadioButtonState = index; + this.dataHandler?.setProps(this._props); + + const byte = this._ancRadioMap[index]; + if (byte != null) + this._nothingBudsSocket?.setNoiseControl(byte); + } + + _box1CheckButton1StateChanged(state) { + if (!this._modelData.personalizeAnc) + return; + + this._nothingBudsSocket?.setPersonalizedAnc(state); + } + + updatePersonalizedAnc(state) { + if (this._props.box1CheckButton1State === state) + return; + + this._props.box1CheckButton1State = state; + this.dataHandler?.setProps(this._props); + } + + updateEqPreset(mode) { + if (this._eqPreset === mode) + return; + + this._eqPreset = mode; + + if (this._settingsItems) { + this._settingsItems['eq-preset'] = mode; + this._updateGsettings(); + } + } + + _setEqPreset(mode) { + if (!this._modelData.eqPreset) + return; + + this._nothingBudsSocket?.setEqPreset(mode); + } + + updateCustomEq(eqArray) { + if (isArrayEqual(this._customEq, eqArray)) + return; + + this._customEq = eqArray; + + if (this._settingsItems) { + this._settingsItems['eq-custom'] = eqArray; + this._updateGsettings(); + } + } + + _setCustomEq(eqArray) { + this._nothingBudsSocket?.setCustomEq(eqArray); + } + + updateEnhancedBass(enable, level) { + const enableChanged = this._bassEnabled !== enable; + const levelChanged = this._bassLevel !== level; + + if (!enableChanged && !levelChanged) + return; + + this._bassEnabled = enable; + this._bassLevel = level; + + if (this._settingsItems) { + if (enableChanged) + this._settingsItems['bass-enable'] = enable; + + if (levelChanged) + this._settingsItems['bass-level'] = level; + + this._updateGsettings(); + } + } + + _setEnhancedBass(enable, level) { + this._nothingBudsSocket?.setEnhancedBass(enable, level); + } + + updateLatency(enable) { + this._lowlatency = enable; + + if (this._settingsItems) { + this._settingsItems['lowlatency'] = enable; + this._updateGsettings(); + } + } + + _setLatency(enable) { + this._nothingBudsSocket?.setLatency(enable); + } + + updateInEar(enable) { + if (this._inEar === enable) + return; + + this._inEar = enable; + + if (this._settingsItems) { + this._settingsItems['inear-enable'] = enable; + this._updateGsettings(); + } + } + + _setInEar(enable) { + this._nothingBudsSocket?.setInEar(enable); + } + + updateSpatialAudio(enable) { + if (this._spatialAudio === enable) + return; + + this._spatialAudio = enable; + + if (this._settingsItems) { + this._settingsItems['spatial'] = enable; + this._updateGsettings(); + } + } + + _setSpatialAudio(enable) { + this._nothingBudsSocket?.setSpatialAudio(enable); + } + + _createDefaultGestures() { + const opts = this._modelData.gestureOptions; + const hex = opts?.default; + if (!hex) + return []; + + const bytes = []; + + for (let i = 0; i < hex.length; i += 2) + bytes.push(parseInt(hex.substr(i, 2), 16)); + + const count = bytes[0]; + const slots = []; + + let offset = 1; + for (let i = 0; i < count; i++) { + if (offset + 4 > bytes.length) + break; + + slots.push({ + device: bytes[offset], + buttonId: bytes[offset + 1], + type: bytes[offset + 2], + action: bytes[offset + 3], + }); + + offset += 4; + } + + return slots; + } + + _isValidGestureSlot(slot) { + const opts = this._modelData.gestureOptions; + if (!opts) + return false; + + const {slots, mapping, gestures} = opts; + + const slotDef = slots.find(s => + s.device === slot.device && + s.buttonId === slot.buttonId && + mapping.gestureTypes[s.type] === slot.type + ); + + if (!slotDef) { + this._log.info( + `Unsupported slot device=${slot.device} button=${slot.buttonId} type=${slot.type}` + ); + return false; + } + + const gestureKey = Object.entries(mapping.gestureTypes) + .find(([, v]) => v === slot.type)?.[0]; + + if (!gestureKey || !gestures[gestureKey]) { + this._log.info(`Unsupported gesture type byte: ${slot.type}`); + return false; + } + + const actionKey = Object.entries(mapping.actions) + .find(([, values]) => values.includes(slot.action))?.[0]; + + if (!actionKey) { + this._log.info(`Unsupported action byte: ${slot.action}`); + return false; + } + + if (!gestures[gestureKey].actions.includes(actionKey)) { + this._log.info( + `Action '${actionKey}' not allowed for gesture '${gestureKey}'` + ); + return false; + } + + return true; + } + + _gestureSlotsEqual(a = [], b = []) { + if (a.length !== b.length) + return false; + + const areEqual = a.every((slot, index) => { + const other = b[index]; + + const sameDevice = slot.device === other.device; + const sameButton = slot.buttonId === other.buttonId; + const sameType = slot.type === other.type; + const sameAction = slot.action === other.action; + + return sameDevice && sameButton && sameType && sameAction; + }); + + return areEqual; + } + + _findGestureDiff(newSlots, oldSlots) { + for (let i = 0; i < newSlots.length; i++) { + const old = oldSlots[i]; + const cur = newSlots[i]; + + if (!old || old.device !== cur.device || old.buttonId !== cur.buttonId || + old.type !== cur.type || old.action !== cur.action) + return cur; + } + return null; + } + + updateGestures(slots) { + const validSlots = []; + + for (const slot of slots) { + if (this._isValidGestureSlot(slot)) + validSlots.push(slot); + } + + this._gestures = validSlots; + + if (this._settingsItems) { + this._settingsItems['gestures'] = validSlots; + this._updateGsettings(); + } + } + + _setGesture(slot) { + this._nothingBudsSocket?.setGesture(slot); + } + + _setRingMyBuds(state, isLeft = false) { + this._nothingBudsSocket?.setRingMyBuds(state, isLeft); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'nothingBuds', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + this._nothingBudsSocket?.destroy(); + this._nothingBudsSocket = null; + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + diff --git a/bbm/lib/devices/nothingBuds/nothingBudsSocket.js b/bbm/lib/devices/nothingBuds/nothingBudsSocket.js new file mode 100644 index 0000000000..2f340f4a5d --- /dev/null +++ b/bbm/lib/devices/nothingBuds/nothingBudsSocket.js @@ -0,0 +1,879 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {booleanFromByte, isValidByte} from '../deviceUtils.js'; +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import { + crc16Ansi, NothingBudsModelList, PayloadType, BatteryType +} from './nothingBudsConfig.js'; + +const HEADER_MAGIC = [0x55, 0x60, 0x01]; +const HEADER_LEN = 8; +const CRC_LEN = 2; + +/** +Nothing Ear module for Bluetooth battery meter service to provide, +battery information, ANC and Convesational awareness on device that support it. + +Credits: +https://github.com/radiance-project/ear-web +Team: radiance-project + +https://github.com/DaanHessen/earctl/ +DaanHessen +**/ + +export const NothingBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_NothingBudsSocket', +}, class NothingBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `NothingEarSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('NothingEarSocket init'); + + this._devicePath = devicePath; + this._seq = 0; + this._rxBuffer = []; + this._txQueue = []; + this._pendingRequest = null; + this._pendingTimeout = null; + this._modelInitialized = false; + + this._callbacks = callbacks; + + this.startSocket(); + } + + _queuePacket(payloadType, payload = [], loginfo = '') { + this._txQueue.push({payloadType, payload, loginfo}); + this._processQueue(); + } + + _processQueue() { + if (this._pendingRequest) + return; + + if (this._txQueue.length === 0) + return; + + const item = this._txQueue.shift(); + + if (item.loginfo) + this._log.info(item.loginfo); + + this._encode(item.payloadType, item.payload); + this._pendingRequest = this._seq; + + if (this._pendingTimeout) { + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + } + + this._pendingTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + this._log.info(`Response Timeout seq: ${this._pendingRequest}`); + this._pendingRequest = null; + this._pendingTimeout = null; + this._processQueue(); + return GLib.SOURCE_REMOVE; + }); + } + + _completePendingRequest(seq) { + if (!this._pendingRequest) + return; + + if (seq !== this._pendingRequest) + return; + + if (this._pendingTimeout) + GLib.source_remove(this._pendingTimeout); + + this._pendingTimeout = null; + this._pendingRequest = null; + this._processQueue(); + } + + _encode(payloadType, payload) { + this._seq = this._seq >= 250 ? 1 : this._seq + 1; + const payloadLen = payload.length; + + const header = [ + ...HEADER_MAGIC, + payloadType & 0xFF, + payloadType >> 8 & 0xFF, + payloadLen, + 0x00, + this._seq, + ]; + + const packet = Uint8Array.from([...header, ...payload]); + const crc = crc16Ansi(packet); + const out = Uint8Array.from([...packet, crc & 0xFF, crc >> 8 & 0xFF]); + this.sendMessage(out); + } + + processData(bytes) { + this._rxBuffer.push(...bytes); + + while (true) { + const msg = this._extractMessage(); + + if (!msg) + break; + + this._completePendingRequest(msg.seq); + this._parseData(msg); + } + } + + _extractMessage() { + const buf = this._rxBuffer; + + for (let i = 0; i < buf.length; i++) { + if (buf[i] !== HEADER_MAGIC[0]) + continue; + + if (buf.length - i < HEADER_LEN) + return null; + + const control = buf[i + 1] | buf[i + 2] << 8; + const hasCrc = (control & 0x20) !== 0; + const payloadLen = buf[i + 5] | buf[i + 6] << 8; + const totalLen = HEADER_LEN + payloadLen + (hasCrc ? CRC_LEN : 0); + + if (i + totalLen > buf.length) + return null; + + const raw = buf.slice(i, i + totalLen); + this._rxBuffer.splice(0, i + totalLen); + return this._parseMessage(raw); + } + + if (buf.length > 300) + this._rxBuffer = []; + + return null; + } + + _parseMessage(raw) { + const payloadLen = raw[5]; + const payloadType = raw[3] | raw[4] << 8; + const seq = raw[7]; + const payload = raw.slice(HEADER_LEN, HEADER_LEN + payloadLen); + + return {payloadType, payload, seq}; + } + + _sendPacket(payloadType, loginfo = '', payload = []) { + this._queuePacket(payloadType, payload, loginfo); + } + + postConnectInitialization() { + this._onPostConnectInitialization(); + } + + _parseData(resp) { + const {payloadType, payload} = resp; + + switch (payloadType) { + case PayloadType.PROTOCOL_RET: + if (!this._modelInitialized) + this._parseModelFromResponse(payload); + break; + + case PayloadType.DEVICE_MODEL_RET: + if (!this._modelInitialized) + this._parseModelFromResponse(payload); + break; + case PayloadType.FIRMWARE_NTFY: + this._parseFirmwareInfo(payload); + break; + case PayloadType.SERIAL_RET: + this._parseSerialNumber(payload); + break; + case PayloadType.BATTERY_PRIMARY_NTFY: + case PayloadType.BATTERY_SECONDARY_NTFY: + if (this._modelData) + this._processBattery(payload); + break; + case PayloadType.ANC_PRIMARY_NTFY: + case PayloadType.ANC_SECONDARY_NTFY: + if (this._modelData?.noiseControl) + this._parseNoiseControl(payload); + break; + case PayloadType.PERSONALIZED_ANC_NTFY: + if (this._modelData?.personalizeAnc) + this._parsePersonalizedAnc(payload); + break; + case PayloadType.EQ_NTFY: + if (this._modelData?.eqPreset && !this._modelData?.eqListeningModeType) + this._parseEqPreset(payload); + break; + case PayloadType.LISTENING_MODE_NTFY: + if (this._modelData?.eqPreset && this._modelData?.eqListeningModeType) + this._parseEqPreset(payload); + break; + case PayloadType.CUSTOM_EQ_NTFY: + if (this._modelData?.eqCustom) + this._parseCustomEq(payload); + break; + case PayloadType.ENHANCED_BASS_RET: + case PayloadType.ENHANCED_BASS_NTFY: + if (this._modelData?.bassEnhanceLevel) + this._parseEnhancedBass(payload); + break; + case PayloadType.LATENCY_RET: + if (this._modelData?.lowLatencyMode) + this._parseLatencyRet(payload); + break; + case PayloadType.LATENCY_NTFY: + if (this._modelData?.lowLatencyMode) + this._parseLatency(payload); + break; + case PayloadType.IN_EAR_NTFY: + if (this._modelData?.inEarDetection) + this._parseInEar(payload); + break; + case PayloadType.GESTURES_NTFY: + if (this._modelData?.gestureOptions) + this._parseGestures(payload); + break; + case PayloadType.SPATIAL_AUDIO_NTFY: + if (this._modelData?.spatialAudioSwitch) + this._parseSpatialAudio(payload); + break; + } + } + + _onPostConnectInitialization() { + this._getProtocol(); + this._setProtocol(); + this._getDeviceModelId(); + + this._modelFallbackTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + if (!this._modelInitialized) + this._getModelByName(); + + this._modelFallbackTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _onModelInitialized(modelData) { + this._log.info('Model Initialized'); + this._modelInitialized = true; + this._modelData = modelData; + this._callbacks?.modelIntialized?.(modelData); + + this._getBattery(); + + if (modelData.eqPreset) + this._getEqPreset(); + + this._getFirmwareInfo(); + this._getSerialInfo(); + + if (!modelData.noUTCTimeUpdate) + this._setUTCtime(); + + if (modelData.inEarDetection) + this._getInEar(); + + if (modelData.lowLatencyMode) + this._getLatency(); + + if (modelData.personalizeAnc) + this._getPersonalizedAnc(); + + if (modelData.gestureOptions) + this._getGestures(); + + if (modelData.noiseControl) { + this._getNoiseControlConfig(); + this._getNoiseControl(); + } + + if (modelData.bassEnhanceLevel) + this._getEnhancedBass(); + + if (this._modelData?.spatialAudioSwitch) + this._getSpatialAudio(); + } + + _getModelByName() { + this._log.info('Serial timeout, falling back to model-by-name'); + const bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const name = bluezDeviceProxy?.Name; + + if (!name) { + this._log.info('Bluetooth device name not available for model fallback'); + return; + } + + const modelData = NothingBudsModelList.find(model => + model.pattern && model.pattern.test(name) + ); + + if (!modelData) { + this._log.info(`No model matched by name fallback: "${name}"`); + return; + } + + this._log.info(`Model resolved by name fallback: ${modelData.name} (${modelData.modelId})`); + this._onModelInitialized(modelData); + } + + _parseModelFromResponse(payload) { + if (!payload || payload.length < 2) { + this._log.info('Model response payload invalid'); + return; + } + + const modelIdNum = payload[1] << 8 | payload[0]; + const modelId = modelIdNum.toString(16).toUpperCase(); + + const modelData = NothingBudsModelList.find(model => model.modelId === modelId); + + if (!modelData) { + this._log.info(`No model matched for ID: ${modelId}`); + return; + } + + this._log.info(`Model resolved by ID: ${modelData.name} (${modelData.modelId})`); + + this._onModelInitialized(modelData); + } + + _getProtocol() { + const loginfo = 'Request Protocol'; + this._sendPacket(PayloadType.PROTOCOL_GET, loginfo); + } + + _setProtocol() { + const loginfo = 'Set Protocol'; + this._sendPacket(PayloadType.PROTOCOL_SET, loginfo); + } + + _getDeviceModelId() { + const loginfo = 'Request DeviceModelId'; + this._sendPacket(PayloadType.DEVICE_MODEL_GET, loginfo); + } + + _getFirmwareInfo() { + const loginfo = 'Request FirmwareInfo'; + this._sendPacket(PayloadType.FIRMWARE_GET, loginfo); + } + + _parseFirmwareInfo(payload) { + this._log.info('Parse FirmwareInfo'); + if (!payload || payload.length < 1) + return; + + const info = String.fromCharCode(...payload); + + this._log.info(`Firmware:${info}`); + this._callbacks?.updateFirmwareInfo?.(info); + } + + _getSerialInfo() { + const loginfo = 'Request SerialInfo'; + this._sendPacket(PayloadType.SERIAL_GET, loginfo); + } + + _parseSerialNumber(payload) { + this._log.info('Parse SerialNumber'); + + if (!payload || payload.length < 2) + return; + + const data = new TextDecoder().decode(new Uint8Array(payload.slice(1))); + + for (const line of data.trim().split('\n')) { + const parts = line.split(','); + + if (parts.length !== 3) + continue; + + const type = Number.parseInt(parts[1], 10); + const value = parts[2]; + + if (type === 4 && value.length > 0) { + this._callbacks?.updateSerial?.(value); + return; + } + } + } + + _setUTCtime() { + const loginfo = 'Set UTCtime'; + const secEpoch = Math.floor(Date.now() / 1000); + const payload = new Uint8Array(4); + payload[0] = secEpoch >> 24 & 0xFF; + payload[1] = secEpoch >> 16 & 0xFF; + payload[2] = secEpoch >> 8 & 0xFF; + payload[3] = secEpoch & 0xFF; + + this._sendPacket(PayloadType.UTC_TIME_SET, loginfo, payload); + } + + _getBattery() { + const loginfo = 'Request battery status'; + this._sendPacket(PayloadType.BATTERY_GET, loginfo); + } + + _processBattery(payload) { + this._log.info('Processing battery status'); + let left = {state: 'disconnected', level: 0}; + let right = {state: 'disconnected', level: 0}; + let caseBat = {state: 'disconnected', level: 0}; + let singleBat = null; + + const count = payload[0]; + + for (let i = 0; i < count; i++) { + const idx = 1 + i * 2; + if (idx + 1 >= payload.length) + break; + + const devId = payload[idx]; + const levelByte = payload[idx + 1]; + + const level = levelByte & 0x7F; + const state = (levelByte & 0x80) !== 0 ? 'charging' : 'discharging'; + + const reading = {level, state}; + + if (devId === BatteryType.LEFT) + left = reading; + else if (devId === BatteryType.RIGHT) + right = reading; + else if (devId === BatteryType.CASE) + caseBat = reading; + else if (devId === BatteryType.SINGLE) + singleBat = reading; + } + + const props = { + battery1Level: singleBat ? singleBat.level : left.level, + battery1Status: singleBat ? singleBat.state : left.state, + + battery2Level: right.level, + battery2Status: right.state, + + battery3Level: caseBat.level, + battery3Status: caseBat.state, + }; + + this._callbacks?.updateBatteryProps?.(props); + } + + _buildNoiseControlByteList() { + this._noiseControlBytes = []; + + const nc = this._modelData?.noiseControl; + + if (nc.off?.byte != null) + this._noiseControlBytes.push(nc.off.byte); + + if (nc.transparency?.byte != null) + this._noiseControlBytes.push(nc.transparency.byte); + + if (nc.noiseCancellation) { + if (nc.noiseCancellation.levels) { + this._noiseControlBytes.push( + ...Object.values(nc.noiseCancellation.levels) + ); + } else if (nc.noiseCancellation.byte != null) { + this._noiseControlBytes.push(nc.noiseCancellation.byte); + } + } + this._log.info(`this._noiseControlBytes = ${this._noiseControlBytes}`); + } + + _getNoiseControlConfig() { + const loginfo = 'Request NoiseControlConfig'; + this._buildNoiseControlByteList(); + this._sendPacket(PayloadType.ANC_CONFIGURATION_GET, loginfo); + } + + _getNoiseControl() { + const loginfo = 'Request NoiseControl'; + this._buildNoiseControlByteList(); + this._sendPacket(PayloadType.ANC_GET, loginfo); + } + + _parseNoiseControl(payload) { + this._log.info('Parse NoiseControl'); + if (!payload || payload.length < 2) + return; + + const mode = payload[1]; + + if (!this._noiseControlBytes?.includes(mode)) + return; + + this._callbacks?.updateNoiseControl?.(mode); + } + + setNoiseControl(mode) { + const loginfo = 'Set NoiseControl'; + const payload = [0x01, mode, 0x00]; + this._sendPacket(PayloadType.ANC_SET, loginfo, payload); + } + + _getPersonalizedAnc() { + const loginfo = 'Request PersonalizedAnc'; + this._sendPacket(PayloadType.PERSONALIZED_ANC_GET, loginfo); + } + + _parsePersonalizedAnc(payload) { + this._log.info('Parse PersonalizedAnc'); + if (!payload || payload.length < 1) + return; + + const state = payload[0]; + const enable = booleanFromByte(state); + if (enable === null) + return; + + this._callbacks?.updatePersonalizedAnc?.(state); + } + + setPersonalizedAnc(state) { + const loginfo = 'Set PersonalizedAnc'; + const payload = [state]; + this._sendPacket(PayloadType.PERSONALIZED_ANC_SET, loginfo, payload); + } + + _getEqPreset() { + const loginfo = 'Request EqPreset'; + const payloadType = this._modelData.eqListeningModeType + ? PayloadType.EQ_GET : PayloadType.LISTENING_MODE_GET; + this._sendPacket(payloadType, loginfo); + } + + _parseEqPreset(payload) { + this._log.info('Parse EqPreset'); + + if (!payload || payload.length < 1) + return; + + const preset = this._modelData.eqPreset; + const mode = payload[0]; + + if (!isValidByte(mode, preset)) + return; + + if (mode === preset.custom) + this._getCustomEq(); + + this._callbacks?.updateEqPreset?.(mode); + } + + setEqPreset(mode) { + const loginfo = 'Set Equalizer'; + + const preset = this._modelData.eqPreset; + + if (!isValidByte(mode, preset)) + return; + + const payloadType = this._modelData.eqListeningModeType + ? PayloadType.LISTENING_MODE_SET : PayloadType.EQ_SET; + + const payload = [mode, 0x00]; + this._sendPacket(payloadType, loginfo, payload); + + if (mode === preset.custom) + this._getCustomEq(); + } + + _getCustomEq() { + const loginfo = 'Request CustomEq'; + this._sendPacket(PayloadType.CUSTOM_EQ_GET, loginfo); + } + + _decodeEqFloat(bytes) { + if (!bytes || bytes.length < 4) + return 0.0; + + const arr = new Uint8Array(4); + arr[0] = bytes[3]; + arr[1] = bytes[2]; + arr[2] = bytes[1]; + arr[3] = bytes[0]; + + const view = new DataView(arr.buffer); + + const isNegative = + arr[0] === 0x00 && + arr[1] === 0x00 && + arr[2] === 0x00 && + (arr[3] & 0x80) === 0x80; + + if (isNegative) { + arr[3] &= 0x7F; + return -view.getFloat32(0, false); + } + + return view.getFloat32(0, false); + } + + + _encodeEqFloat(value, total = false) { + if (total && value >= 0.0) + return [0x00, 0x00, 0x00, 0x80]; + + const buf = new ArrayBuffer(4); + const view = new DataView(buf); + view.setFloat32(0, value, false); + + const bytes = new Uint8Array(buf); + + if (value !== 0.0 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x00) + bytes[3] |= 0x80; + + return [bytes[3], bytes[2], bytes[1], bytes[0]]; + } + + _parseCustomEq(payload) { + if (!payload || payload.length < 45) + return; + + this._log.info('Parse CustomEq'); + + const levels = []; + + for (let band = 0; band < 3; band++) { + const offset = 6 + band * 13; + if (offset + 4 > payload.length) + return; + + const slice = payload.slice(offset, offset + 4); + levels.push(this._decodeEqFloat(slice)); + } + + const clamp = v => Math.min(6, Math.max(-6, v)); + + const customEqArray = [ + clamp(levels[2]), + clamp(levels[0]), + clamp(levels[1]), + ]; + + this._log.info(`CustomEq values: [Bass=${customEqArray[0]},` + + `Mid=${customEqArray[1]}, Treble=${customEqArray[2]}]`); + + this._callbacks?.updateCustomEq?.(customEqArray); + } + + setCustomEq(eqArray) { + const loginfo = 'Parse CustomEq'; + const payload = new Uint8Array([ + 0x03, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x75, 0x44, + 0xc3, 0xf5, 0x28, 0x3f, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x5a, 0x45, 0x00, + 0x00, 0x80, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x43, 0xcd, 0xcc, + 0x4c, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]); + + const [bass, mid, treble] = eqArray; + const values = [mid, treble, bass]; + + let highest = 0.0; + for (const v of values) + highest = Math.max(highest, Math.abs(v)); + + const totalBytes = this._encodeEqFloat(-highest, true); + payload.set(totalBytes, 1); + + values.forEach((value, index) => { + const offset = 6 + index * 13; + const bytes = this._encodeEqFloat(value, false); + payload.set(bytes, offset); + }); + + this._sendPacket(PayloadType.CUSTOM_EQ_SET, loginfo, payload); + } + + _getEnhancedBass() { + const loginfo = 'Request EnhancedBass'; + this._sendPacket(PayloadType.ENHANCED_BASS_GET, loginfo); + } + + _parseEnhancedBass(payload) { + this._log.info('Parse EnhancedBass'); + if (!payload || payload.length < 2) + return; + + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + const level = payload[1] / 2; + this._callbacks?.updateEnhancedBass?.(enable, level); + } + + setEnhancedBass(enable, level) { + const loginfo = 'Set EnhancedBass'; + const payload = [enable ? 0x01 : 0x00, level * 2]; + this._sendPacket(PayloadType.ENHANCED_BASS_SET, loginfo, payload); + } + + _getLatency() { + const loginfo = 'Request Latency'; + this._sendPacket(PayloadType.LATENCY_GET, loginfo); + } + + _parseLatencyRet(payload) { + this._log.info('Parse Latency'); + if (!payload || payload.length < 1) + return; + + const enable = payload[0] === 0x01; + this._callbacks?.updateLatency?.(enable); + } + + _parseLatency(payload) { + this._log.info('Parse Latency'); + if (!payload || payload.length < 2) + return; + + const enable = payload[0] === 0x01; + this._callbacks?.updateLatency?.(enable); + } + + setLatency(enable) { + const loginfo = 'Set Latency'; + const payload = [enable ? 0x01 : 0x02, 0x00]; + this._sendPacket(PayloadType.LATENCY_SET, loginfo, payload); + } + + _getInEar() { + const loginfo = 'Request InEar'; + this._sendPacket(PayloadType.IN_EAR_GET, loginfo); + } + + _parseInEar(payload) { + this._log.info('Parse InEar'); + if (!payload || payload.length < 3) + return; + + const enable = booleanFromByte(payload[2]); + if (enable === null) + return; + + this._callbacks?.updateInEar?.(enable); + } + + setInEar(enable) { + const loginfo = 'Set InEar'; + const payload = [0x00, 0x00, enable ? 0x01 : 0x00]; + this._sendPacket(PayloadType.IN_EAR_SET, loginfo, payload); + } + + setRingMyBuds(state, isLeft) { + const loginfo = 'Set RingMyBuds'; + const payload = []; + + if (!this._modelData.ringLegacy) { + if (this._modelData.batterySingle) + payload.push(0x06); + else + payload.push(isLeft ? 0x02 : 0x03); + } + + payload.push(state === 'playing' ? 0x01 : 0x00); + + this._sendPacket(PayloadType.RING_SET, loginfo, payload); + } + + _getGestures() { + const loginfo = 'Request Gestures'; + this._sendPacket(PayloadType.GESTURES_GET, loginfo); + } + + _parseGestures(payload) { + this._log.info('Parse Gestures'); + + if (!payload || payload.length < 1) + return; + + const count = payload[0]; + const slots = []; + + let offset = 1; + for (let i = 0; i < count; i++) { + if (offset + 3 >= payload.length) + break; + + slots.push({ + device: payload[offset], + buttonId: payload[offset + 1], + type: payload[offset + 2], + action: payload[offset + 3], + }); + + offset += 4; + } + + this._callbacks?.updateGestures?.(slots); + } + + setGesture(slot) { + const loginfo = 'Set Gesture'; + + if (!slot) + return; + + const payload = [ + 0x01, + slot.device & 0xFF, + slot.buttonId & 0xFF, + slot.type & 0xFF, + slot.action & 0xFF, + ]; + + this._sendPacket(PayloadType.GESTURES_SET, loginfo, payload); + } + + _getSpatialAudio() { + const loginfo = 'Request SpatialAudio'; + this._sendPacket(PayloadType.SPATIAL_AUDIO_GET, loginfo); + } + + _parseSpatialAudio(payload) { + this._log.info('Parse SpatialAudio'); + if (!payload || payload.length < 1) + return; + + const enable = payload[0] === 0x01; + this._callbacks?.updateSpatialAudio?.(enable); + } + + setSpatialAudio(enable) { + const loginfo = 'Set SpatialAudio'; + const payload = [enable ? 0x01 : 0x00]; + this._sendPacket(PayloadType.SPATIAL_AUDIO_SET, loginfo, payload); + } + + destroy() { + if (this._modelFallbackTimeoutId) { + GLib.Source.remove(this._modelFallbackTimeoutId); + this._modelFallbackTimeoutId = null; + } + + if (this._pendingTimeout) { + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + } + + super.destroy(); + } +}); + + diff --git a/bbm/lib/devices/notifier.js b/bbm/lib/devices/notifier.js new file mode 100644 index 0000000000..9bac6281ed --- /dev/null +++ b/bbm/lib/devices/notifier.js @@ -0,0 +1,91 @@ +'use strict'; +import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {DeviceTypeAirpods} from './airpods/airpodsDevice.js'; +import {DeviceTypeSonyV1, DeviceTypeSonyV2} from './sony/sonyDevice.js'; +import {DeviceTypeGalaxyLegacy, DeviceTypeGalaxyBuds} from './galaxyBuds/galaxyBudsDevice.js'; +import {DeviceTypeNothingBuds} from './nothingBuds/nothingBudsDevice.js'; +import {DeviceTypeGoogleBuds} from './googleBuds/googleBudsDevice.js'; +import {DeviceTypeRedmiBuds} from './redmiBuds/redmiBudsDevice.js'; +import {DeviceTypeSenhBuds} from './senhBuds/senhBudsDevice.js'; +import {DeviceTypeBoseBuds} from './boseBuds/boseBudsDevice.js'; +import {DeviceTypeGfps} from './gfps/gfpsDevice.js'; + +export class Notifier { + constructor(toggle) { + this.toggle = toggle; + this._notification = null; + this._source = null; + } + + notifyProfileRegisteredError(type) { + let label; + if (type === DeviceTypeAirpods) + label = _('AirPods / Beats Bluetooth audio devices'); + else if (type === DeviceTypeSonyV1 || type === DeviceTypeSonyV2) + label = _('Sony Bluetooth audio devices'); + else if (type === DeviceTypeGalaxyLegacy || type === DeviceTypeGalaxyBuds) + label = _('Samsung Galaxy Bluetooth audio devices'); + else if (type === DeviceTypeNothingBuds) + label = _('Nothing / CMF Bluetooth audio devices'); + else if (type === DeviceTypeGoogleBuds) + label = _('Google Pixel Buds Bluetooth audio devices'); + else if (type === DeviceTypeRedmiBuds) + label = _('Redmi / Xiaomi Bluetooth audio devices'); + else if (type === DeviceTypeSenhBuds) + label = _('Sennheiser Bluetooth audio devices'); + else if (type === DeviceTypeBoseBuds) + label = _('Bose Bluetooth audio devices'); + else if (type === DeviceTypeGfps) + label = _('Google Fast Pair Bluetooth audio devices'); + else + label = type; + + if (this._notification) + this._notification.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + this._notification = null; + + if (!this._source) { + this._source = new MessageTray.Source({ + title: _('Bluetooth Battery Meter'), + icon: this.toggle.gIcon('bbm-logo-symbolic.svg'), + }); + } + + this._source.connectObject('destroy', () => { + this._source = null; + }, this._source); + + Main.messageTray.add(this._source); + + const title = _('Could not access advanced features for %s.').format(label); + const body = _( + 'Another app or session is already using the Bluetooth socket/profile on %s.' + + ' Close any other apps using this device,' + + ' then disable and re-enable it in the extension settings.' + ).format(label); + + this._notification = new MessageTray.Notification({ + source: this._source, + title, + body, + urgency: MessageTray.Urgency.NORMAL, + }); + this._notification.connectObject('destroy', () => { + this._notification = null; + }, this); + + this._source.addNotification(this._notification); + } + + destroy() { + if (this._notification) + this._notification.destroy(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + this._notification = null; + + this._source?.destroy(); + this._source = null; + } +} diff --git a/bbm/lib/devices/profileManager.js b/bbm/lib/devices/profileManager.js new file mode 100644 index 0000000000..6164350d24 --- /dev/null +++ b/bbm/lib/devices/profileManager.js @@ -0,0 +1,374 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, sanitizeDevPath} from './logger.js'; + +const SERVICE_PATH = '/com/github/maniacx/BluetoothBatteryMeter/Profile'; + +export const ProfileManager = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_ProfileManager', + Signals: { + 'new-connection': { + param_types: [GObject.TYPE_STRING, GObject.TYPE_INT], + }, + }, +}, class ProfileManager extends GObject.Object { + _init(notifyCb) { + super._init(); + this._log = createLogger('ProfileManager'); + this._systemBus = Gio.bus_get_sync(Gio.BusType.SYSTEM, null); + this._interface = this._buildInterfaceInfo(); + this._profiles = new Map(); + this._fdByDevice = new Map(); + this._notifyCb = notifyCb; + } + + _buildInterfaceInfo() { + const xml = ` + + + + + + + + + + + + + `; + return Gio.DBusNodeInfo.new_for_xml(xml).interfaces[0]; + } + + async _registerProfile(deviceType, uuid) { + let registrationId = 0; + let proxy = null; + const objectPath = `${SERVICE_PATH}/${deviceType}`; + + this._log.info(`Registering profile ${deviceType}`); + + try { + registrationId = this._systemBus.register_object( + objectPath, + this._interface, + this._onMethodCall.bind(this, deviceType), + null, + null + ); + } catch (e) { + this._log.error(e, `Failed to register object for ${deviceType}`); + return null; + } + + try { + proxy = await Gio.DBusProxy.new( + this._systemBus, + Gio.DBusProxyFlags.NONE, + null, + 'org.bluez', + '/org/bluez', + 'org.bluez.ProfileManager1', + null + ); + } catch (e) { + this._log.error(e, 'Failed to create ProfileManager1 proxy'); + this._systemBus.unregister_object(registrationId); + return null; + } + + const opts = { + Name: new GLib.Variant('s', `CustomProfile-${deviceType}`), + Role: new GLib.Variant('s', 'client'), + AutoConnect: new GLib.Variant('b', true), + }; + + try { + await proxy.call( + 'RegisterProfile', + GLib.Variant.new_tuple([ + new GLib.Variant('o', objectPath), + new GLib.Variant('s', uuid), + new GLib.Variant('a{sv}', opts), + ]), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (e) { + const name = Gio.DBusError.get_remote_error(e); + + if (name === 'org.bluez.Error.NotPermitted' || + name === 'org.bluez.Error.AlreadyExists') + this._notifyCb(deviceType); + + this._log.error(e, `RegisterProfile failed for ${deviceType}`); + this._systemBus.unregister_object(registrationId); + return null; + } + + return {proxy, objectPath, registrationId}; + } + + _unregisterProfile(deviceType, profile) { + if (!profile.proxy || !profile.objectPath || !profile.registrationId) + return; + + this._log.info(`Unregistering profile ${deviceType}`); + + try { + profile.proxy.call_sync( + 'UnregisterProfile', + GLib.Variant.new_tuple([ + new GLib.Variant('o', profile.objectPath), + ]), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (e) { + this._log.error(e, `Error while unregistering profile for ${deviceType}`); + } + + try { + this._systemBus.unregister_object(profile.registrationId); + } catch { + // do nothing + } + + this._profiles.delete(deviceType); + } + + _onMethodCall(deviceType, conn, sender, path, iface, method, params, invocation) { + if (method === 'Release') { + this._log.info(`Profile Release ${deviceType}`); + invocation.return_value(null); + return; + } + + if (method === 'NewConnection') { + this._log.info(`Profile NewConnection ${deviceType}`); + const [devicePath, fdIndex] = params.deep_unpack(); + const fdList = invocation.get_message().get_unix_fd_list(); + const fd = fdList.get(fdIndex); + + let entry = this._fdByDevice.get(devicePath); + if (!entry) { + entry = { + deviceType, + fd, + timeoutId: null, + signalId: null, + resolve: null, + }; + this._fdByDevice.set(devicePath, entry); + } else { + entry.fd = fd; + } + + this.emit('new-connection', devicePath, fd); + invocation.return_value(null); + return; + } + + if (method === 'RequestDisconnection') { + const [devicePath] = params.deep_unpack(); + this._log.info(`RequestDisconnection ${devicePath}`); + this.releaseFd(deviceType, devicePath, false); + invocation.return_value(null); + } + } + + async _getDeviceProxy(devicePath) { + try { + const deviceProxy = await Gio.DBusProxy.new( + this._systemBus, + Gio.DBusProxyFlags.NONE, + null, + 'org.bluez', + devicePath, + 'org.bluez.Device1', + null + ); + return deviceProxy; + } catch (e) { + this._log.error(e, `Failed to create Device1 proxy for ${devicePath}`); + return null; + } + } + + async connectProfile(deviceType, devicePath) { + const profile = this._profiles.get(deviceType); + if (!profile) + return; + + const deviceProxy = await this._getDeviceProxy(devicePath); + if (!deviceProxy) + return; + + try { + await deviceProxy.call( + 'ConnectProfile', + GLib.Variant.new_tuple([new GLib.Variant('s', profile.uuid)]), + Gio.DBusCallFlags.NONE, + -1, + null + ); + + this._log.info( + `ConnectProfile OK for ${profile.uuid} on ${sanitizeDevPath(devicePath)}` + ); + } catch { + // do nothing + } + } + + async disconnectProfile(deviceType, devicePath) { + const profile = this._profiles.get(deviceType); + if (!profile) + return; + + const deviceProxy = await this._getDeviceProxy(devicePath); + if (!deviceProxy) + return; + + try { + await deviceProxy.call( + 'DisconnectProfile', + GLib.Variant.new_tuple([ + new GLib.Variant('s', profile.uuid), + ]), + Gio.DBusCallFlags.NONE, + -1, + null + ); + + this._log.info( + `DisconnectProfile OK for ${profile.uuid} on ${sanitizeDevPath(devicePath)}` + ); + } catch { + // do nothing + } + } + + async acquireFd(deviceType, uuid, devicePath) { + if (!this._profiles.has(deviceType)) { + const iniEntry = { + uuid, + proxy: null, + objectPath: null, + registrationId: null, + }; + this._profiles.set(deviceType, iniEntry); + + const info = await this._registerProfile(deviceType, uuid); + if (!info) { + this._profiles.delete(deviceType); + return -1; + } + + iniEntry.proxy = info.proxy; + iniEntry.objectPath = info.objectPath; + iniEntry.registrationId = info.registrationId; + } + + let entry = this._fdByDevice.get(devicePath); + if (!entry) { + entry = { + deviceType, + fd: null, + timeoutId: null, + signalId: null, + resolve: null, + }; + this._fdByDevice.set(devicePath, entry); + } else if (entry.fd !== null) { + return entry.fd; + } + + return new Promise(resolve => { + entry.resolve = resolve; + let attempt = 0; + + entry.signalId = this.connect('new-connection', (_o, path, fd) => { + if (path !== devicePath) + return; + + entry.fd = fd; + + if (entry.timeoutId) + GLib.source_remove(entry.timeoutId); + entry.timeoutId = null; + + this.disconnect(entry.signalId); + entry.signalId = null; + + resolve(fd); + }); + + entry.timeoutId = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + 500, + () => { + if (!this._fdByDevice.has(devicePath)) + return GLib.SOURCE_REMOVE; + + attempt++; + + if (attempt === 1 || attempt === 2 || attempt === 4 || attempt === 8) + this.connectProfile(deviceType, devicePath); + + if (attempt > 8) { + if (entry.signalId) { + this.disconnect(entry.signalId); + entry.signalId = null; + } + this._fdByDevice.delete(devicePath); + entry.timeoutId = null; + resolve(-1); + return GLib.SOURCE_REMOVE; + } + + return GLib.SOURCE_CONTINUE; + } + ); + + this.connectProfile(deviceType, devicePath); + }); + } + + + async releaseFd(deviceType, devicePath, disconnect = true) { + const entry = this._fdByDevice.get(devicePath); + if (!entry) + return; + + if (entry.signalId) + this.disconnect(entry.signalId); + + if (entry.timeoutId) + GLib.source_remove(entry.timeoutId); + + if (entry.resolve) { + entry.resolve(-1); + entry.resolve = null; + } + + this._fdByDevice.delete(devicePath); + + if (disconnect) + await this.disconnectProfile(deviceType, devicePath); + + const hasOtherDevices = + Array.from(this._fdByDevice.values()).some(e => e.deviceType === deviceType); + + if (!hasOtherDevices) { + const profile = this._profiles.get(deviceType); + if (profile) + this._unregisterProfile(deviceType, profile); + } + } +}); + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/FlipBudsPro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/FlipBudsPro.js new file mode 100644 index 0000000000..de013e3cac --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/FlipBudsPro.js @@ -0,0 +1,79 @@ +'use strict'; + +export default { + name: 'FlipBuds Pro', + id: { + vid: [0x0001], + pid: [0x0000, 0x0001], + }, + + batteryMutiple: true, + batteryCase: true, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'tap', + actions: { + 'skip-forward': 0x03, + 'volume-up': 0x04, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'skip-back': 0x02, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiAirDots3Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiAirDots3Pro.js new file mode 100644 index 0000000000..beb17fd7b7 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiAirDots3Pro.js @@ -0,0 +1,103 @@ +'use strict'; + +export default { + name: 'Redmi AirDots 3 Pro', + id: { + vid: [0x5A4D], + pid: [0xEA03, 0xEA0D], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + balanced: 0x15, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + adaptiveChat: true, // 5,10,15,close + + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3.js new file mode 100644 index 0000000000..7d53f23b9d --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3.js @@ -0,0 +1,57 @@ +'use strict'; + +export default { + name: 'Redmi Buds 3', + id: { + vid: [0x2717], + pid: [0x5027, 0x502A, 0x5066], + }, + + batteryMutiple: true, + batteryCase: true, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'no-action': 0x00, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem3', + budsIcon: 'earbuds-stem3', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3Pro.js new file mode 100644 index 0000000000..58f71d8067 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3Pro.js @@ -0,0 +1,88 @@ +'use strict'; + +export default { + name: 'Redmi Buds 3 Pro', + id: { + vid: [0x5A4D], + pid: [0xEA0A, 0xEA0B], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-narrow', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4.js new file mode 100644 index 0000000000..db997f19cf --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4.js @@ -0,0 +1,78 @@ +'use strict'; + +export default { + name: 'Redmi Buds 4', + id: { + vid: [0x2717], + pid: [0x5037, 0x505D], + }, + + batteryMutiple: true, + batteryCase: true, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Active.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Active.js new file mode 100644 index 0000000000..b512c88f8c --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Active.js @@ -0,0 +1,17 @@ +'use strict'; + +export default { + name: 'Redmi Buds 4 Active', + id: { + vid: [0x2717], + pid: [0x5069, 0x505F], + }, + + batteryMutiple: true, + batteryCase: true, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Pro.js new file mode 100644 index 0000000000..c7ddfd0bc3 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Pro.js @@ -0,0 +1,90 @@ +'use strict'; + +export default { + name: 'Redmi Buds 4 Pro', + id: { + vid: [0x5A4D], + pid: [0xEA0E, 0xEA0F], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + balanced: 0x15, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + adaptiveNcSwitch: true, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + immersiveSound: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5.js new file mode 100644 index 0000000000..24fbb26ffd --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5.js @@ -0,0 +1,99 @@ +'use strict'; + +export default { + name: 'Redmi Buds 5', + id: { + vid: [0x2717], + pid: [0x506A, 0x506B, 0x5075], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + lowLatencyMode: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5Pro.js new file mode 100644 index 0000000000..c9108542b1 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5Pro.js @@ -0,0 +1,108 @@ +'use strict'; + +export default { + name: 'Redmi Buds 5 Pro', + id: { + vid: [0x2717], + pid: [0x506C, 0x506D, 0x506F], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + adaptiveNcSwitch: true, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6.js new file mode 100644 index 0000000000..55fb586f4b --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6.js @@ -0,0 +1,100 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6', + id: { + vid: [0x2717], + pid: [0x509F, 0x50A0], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Active.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Active.js new file mode 100644 index 0000000000..6caea0cce8 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Active.js @@ -0,0 +1,85 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6 Active', + id: { + vid: [0x2717], + pid: [0x5088, 0x5089], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Lite.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Lite.js new file mode 100644 index 0000000000..763cee8634 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Lite.js @@ -0,0 +1,92 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6 Lite', + id: { + vid: [0x2717], + pid: [0x508A, 0x508B], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Play.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Play.js new file mode 100644 index 0000000000..b9bc283199 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Play.js @@ -0,0 +1,93 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6 Play', + id: { + vid: [0x2717], + pid: [0x509B], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + }, + + inEarDetection: false, + lowLatencyMode: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'voice-assistant': 0x00, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'voice-assistant': 0x00, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'voice-assistant': 0x00, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'no-action': 0x00, + 'voice-assistant': 0x08, + 'take-photo': 0x09, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + }, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Pro.js new file mode 100644 index 0000000000..1e229a2cc0 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Pro.js @@ -0,0 +1,116 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6 Pro', + id: { + vid: [0x2717], + pid: [0x509D, 0x509E, 0x50AF], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + adaptiveNcSwitch: true, + personalizeAnc: true, + + noiseCancellationStrength: { + low: 0x01, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + 'swipe': 0x05, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + 'swipe': { + type: 'swipe', + actions: { + 'no-action': 0x00, + 'change-volume': 0x0B, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6s.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6s.js new file mode 100644 index 0000000000..e5d72204a0 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6s.js @@ -0,0 +1,96 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6s', + id: { + vid: [0x2717], + pid: [0x5095], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + }, + + noiseCancellationStrength: { + low: 0x00, + high: 0x02, + }, + + + inEarDetection: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds7s.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds7s.js new file mode 100644 index 0000000000..563e6bb73a --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds7s.js @@ -0,0 +1,98 @@ +'use strict'; + +export default { + name: 'Redmi Buds 7s', + id: { + vid: [0x2717], + pid: [0x50B9], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + }, + + noiseCancellationStrength: { + low: 0x00, + high: 0x02, + }, + + inEarDetection: true, + dualConnection: true, + adaptiveSound: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8.js new file mode 100644 index 0000000000..b00b43f727 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8.js @@ -0,0 +1,100 @@ +'use strict'; + +export default { + name: 'Redmi Buds 8', + id: { + vid: [0x2717], + pid: [0x50F2], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Active.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Active.js new file mode 100644 index 0000000000..873fb42ea7 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Active.js @@ -0,0 +1,86 @@ +'use strict'; + +export default { + name: 'Redmi Buds 8 Active', + id: { + vid: [0x2717], + pid: [0x50E1, 0x50E2], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + }, + + inEarDetection: true, + lowLatencyMode: true, + dualConnection: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Lite.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Lite.js new file mode 100644 index 0000000000..7a209f8dad --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Lite.js @@ -0,0 +1,95 @@ +'use strict'; + +export default { + name: 'Redmi Buds 8 Lite', + id: { + vid: [0x2717], + pid: [0x50EE, 0x50EF], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + inEarDetection: true, + dualConnection: true, + lowLatencyMode: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'no-action': 0x00, + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Pro.js new file mode 100644 index 0000000000..3c1ad8512e --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Pro.js @@ -0,0 +1,111 @@ +'use strict'; + +export default { + name: 'Redmi Buds 6 Pro', + id: { + vid: [0x2717], + pid: [0x50E3, 0x50E5], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + adaptiveNcSwitch: true, + ancLevel: 20, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + 'swipe': 0x05, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'double': { + type: 'tap', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'tap', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + 'swipe': { + type: 'swipe', + actions: { + 'no-action': 0x00, + 'change-volume': 0x0B, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBudsSE.js b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBudsSE.js new file mode 100644 index 0000000000..2d2239eb1b --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBudsSE.js @@ -0,0 +1,72 @@ +'use strict'; + +export default { + name: 'Redmi Buds SE', + id: { + vid: [0x2717], + pid: [0x509A], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + }, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiAirSE.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiAirSE.js new file mode 100644 index 0000000000..ca529b839c --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiAirSE.js @@ -0,0 +1,84 @@ +'use strict'; + +export default { + name: 'Xiaomi Air SE', + id: { + vid: [0x2717], + pid: [0x509C], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + boostVolume: 0x07, + }, + + inEarDetection: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3.js new file mode 100644 index 0000000000..af1230ffbe --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3.js @@ -0,0 +1,57 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 3', + id: { + vid: [0x2717], + pid: [0x5026, 0x502B, 0x5066, 0x505E, 0x505A], + }, + + batteryMutiple: true, + batteryCase: true, + + inEarDetection: true, + lowLatencyMode: true, + ring: true, + + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'no-action': 0x00, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem3', + budsIcon: 'earbuds-stem3', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3Pro.js new file mode 100644 index 0000000000..f8d1523a00 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3Pro.js @@ -0,0 +1,88 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 3 Pro', + id: { + vid: [0x2717], + pid: [0x5025], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-narrow', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3TPro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3TPro.js new file mode 100644 index 0000000000..d70579d5bf --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3TPro.js @@ -0,0 +1,83 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 3T Pro', + id: { + vid: [0x2717], + pid: [0x502D], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + noiseCancellationStrength: { + low: 0x01, + mid: 0x00, + high: 0x02, + adaptive: 0x03, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + }, + + inEarDetection: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'volume-up': 0x04, + 'skip-forward': 0x03, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4.js new file mode 100644 index 0000000000..6fd3672e03 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4.js @@ -0,0 +1,72 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 4', + id: { + vid: [0x2717], + pid: [0x5044], + }, + + batteryMutiple: true, + batteryCase: true, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + }, + + adaptiveNcSwitch: true, + + noiseCancellationStrength: { + low: 0x01, + high: 0x00, + }, + + inEarDetection: true, + immersiveSound: true, + lowLatencyMode: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'skip-forward': 0x03, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'press', + actions: { + 'skip-back': 0x02, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + }, + }, + }, + noiseControlModes: ['off', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-narrow', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4Pro.js new file mode 100644 index 0000000000..81cba30529 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4Pro.js @@ -0,0 +1,95 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 4 Pro', + id: { + vid: [0x2717], + pid: [0x5035, 0x2717, 0x503B], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + base: 0x05, + voice: 0x01, + classic: 0x0B, + legendary: 0x0C, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + adaptiveNcSwitch: true, + ancLevel: 6, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + lowLatencyMode: true, + adaptiveSound: true, + dualConnection: true, + autoAnswer: true, + ring: true, + + voiceDetection: true, // Off, 5sec, 10sec, 15sec, Keep on + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'double': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'noise-control': 0x06, + 'no-action': 0x00, + }, + }, + }, + noiseControlModes: ['off', 'transparency', 'noise-cancellation'], + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5.js new file mode 100644 index 0000000000..f05c1c8aa5 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5.js @@ -0,0 +1,100 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 5', + id: { + vid: [0x2717], + pid: [0x5081, 0x5082], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + harman: 0x0E, + treble: 0x06, + base: 0x05, + voice: 0x01, + classic: 0x0B, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + }, + + adaptiveNcSwitch: true, + + noiseCancellationStrength: { + low: 0x00, + high: 0x02, + }, + + inEarDetection: true, + dualConnection: true, + adaptiveSound: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5Pro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5Pro.js new file mode 100644 index 0000000000..68eafb9dc7 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5Pro.js @@ -0,0 +1,118 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 5 Pro', + id: { + vid: [0x2717], + pid: [0x50AD, 0x50B4, 0x50AB, 0x50AC], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + harman: 0x0E, + harmanmaster: 0x0F, + treble: 0x06, + base: 0x05, + voice: 0x01, + legendary: 0x0C, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + transparency: 0x02, + }, + + adaptiveNcSwitch: true, + + noiseCancellationStrength: { + low: 0x01, + high: 0x02, + }, + + transparencyStrength: { + regular: 0x00, + voice: 0x01, + ambient: 0x02, + }, + + inEarDetection: true, + immersiveSound: true, + dualConnection: true, + adaptiveSound: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + 'swipe': 0x05, + }, + gestures: { + 'single': { + type: 'pinch', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'pinch', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'pinch', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'pinch', + actions: { + 'no-action': 0x00, + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + 'swipe': { + type: 'swipe', + actions: { + 'no-action': 0x00, + 'change-volume': 0x0B, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem', + budsIcon: 'earbuds-stem', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds6.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds6.js new file mode 100644 index 0000000000..b82303d8d0 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds6.js @@ -0,0 +1,110 @@ +'use strict'; + +export default { + name: 'Xiaomi Buds 6', + id: { + vid: [0x2717], + pid: [0x50EA], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + harman: 0x0E, + harmanmaster: 0x0F, + soothingboost: 0x0D, + treble: 0x06, + voice: 0x01, + custom: 0x0A, + }, + + noiseControl: { + off: 0x00, + noiseCancellation: 0x01, + }, + + adaptiveNcSwitch: true, + + noiseCancellationStrength: { + low: 0x00, + high: 0x02, + }, + + inEarDetection: true, + dualConnection: true, + immersiveSound: true, + adaptiveSound: true, + autoAnswer: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + 'swipe': 0x05, + }, + gestures: { + 'single': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'press', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'no-action': 0x00, + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + 'swipe': { + type: 'swipe', + actions: { + 'no-action': 0x00, + 'change-volume': 0x0B, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-stem2', + budsIcon: 'earbuds-stem2', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWear.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWear.js new file mode 100644 index 0000000000..9961ad4e92 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWear.js @@ -0,0 +1,83 @@ +'use strict'; + +export default { + name: 'Xiaomi open Wear', + id: { + vid: [0x2717], + pid: [0x50B8, 0x50D7], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + standard: 0x00, + treble: 0x06, + voice: 0x01, + }, + + inEarDetection: true, + dualConnection: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-wingtip2', + budsIcon: 'earbuds-wingtip2', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWearPro.js b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWearPro.js new file mode 100644 index 0000000000..f84d8835c6 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWearPro.js @@ -0,0 +1,85 @@ +'use strict'; + +export default { + name: 'Xiaomi Ppen Wear Pro', + id: { + vid: [0x2717], + pid: [0x507F, 0x5080], + }, + + batteryMutiple: true, + batteryCase: true, + + eqPreset: { + balanced: 0x15, + harman: 0x0E, + harmanmaster: 0x0F, + treble: 0x06, + voice: 0x01, + }, + + inEarDetection: true, + dualConnection: true, + ring: true, + + gestureOptions: { + positions: { + left: 0x00, + right: 0x01, + }, + gestureTypes: { + 'single': 0x04, + 'double': 0x01, + 'triple': 0x02, + 'action-hold': 0x03, + }, + gestures: { + 'single': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + 'take-photo': 0x09, + }, + }, + 'double': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'triple': { + type: 'tap', + actions: { + 'no-action': 0x08, + 'play-pause': 0x01, + 'skip-back': 0x02, + 'skip-forward': 0x03, + 'volume-up': 0x04, + 'volume-down': 0x05, + }, + }, + 'action-hold': { + type: 'press', + actions: { + 'voice-assistant': 0x08, + 'take-photo': 0x09, + }, + }, + }, + }, + + albumArtIcon: 'earbuds-wingtip2', + budsIcon: 'earbuds-wingtip2', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/redmiBuds/redmiBudsAuthenticator.js b/bbm/lib/devices/redmiBuds/redmiBudsAuthenticator.js new file mode 100644 index 0000000000..2f445f2432 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/redmiBudsAuthenticator.js @@ -0,0 +1,183 @@ +'use strict'; +import Gio from 'gi://Gio'; + +const PATTERN = 0x9999; +const BLOCK = 16; +const SEQ = Uint8Array.from([ + 0x11, 0x22, 0x33, 0x33, + 0x22, 0x11, 0x11, 0x22, + 0x33, 0x33, 0x22, 0x11, + 0x11, 0x22, 0x33, 0x33, +]); + +const COEFFICIENTS = [ + [2, 1, 1, 1, 4, 2, 1, 1, 2, 2, 4, 2, 4, 4, 16, 8], + [2, 1, 1, 1, 4, 2, 1, 1, 1, 1, 2, 1, 2, 2, 8, 4], + [1, 1, 4, 2, 2, 2, 4, 2, 16, 8, 4, 4, 2, 1, 1, 1], + [1, 1, 4, 2, 1, 1, 2, 1, 8, 4, 2, 2, 2, 1, 1, 1], + [16, 8, 2, 2, 4, 2, 4, 4, 1, 1, 4, 2, 1, 1, 2, 1], + [8, 4, 1, 1, 2, 1, 2, 2, 1, 1, 4, 2, 1, 1, 2, 1], + [2, 2, 4, 2, 4, 4, 16, 8, 2, 1, 1, 1, 4, 2, 1, 1], + [1, 1, 2, 1, 2, 2, 8, 4, 2, 1, 1, 1, 4, 2, 1, 1], + [4, 2, 4, 4, 16, 8, 2, 2, 1, 1, 2, 1, 1, 1, 4, 2], + [2, 1, 2, 2, 8, 4, 1, 1, 1, 1, 2, 1, 1, 1, 4, 2], + [4, 4, 16, 8, 1, 1, 2, 1, 4, 2, 1, 1, 4, 2, 2, 2], + [2, 2, 8, 4, 1, 1, 2, 1, 4, 2, 1, 1, 2, 1, 1, 1], + [1, 1, 2, 1, 1, 1, 4, 2, 4, 4, 16, 8, 2, 2, 4, 2], + [1, 1, 2, 1, 1, 1, 4, 2, 2, 2, 8, 4, 1, 1, 2, 1], + [4, 2, 1, 1, 2, 1, 1, 1, 4, 2, 2, 2, 16, 8, 4, 4], + [4, 2, 1, 1, 2, 1, 1, 1, 2, 1, 1, 1, 8, 4, 2, 2], +]; + +/* eslint-disable max-len */ +const EXP_TAB = Uint8Array.of( + 0x01, 0x2D, 0xE2, 0x93, 0xBE, 0x45, 0x15, 0xAE, 0x78, 0x03, 0x87, 0xA4, 0xB8, 0x38, 0xCF, 0x3F, + 0x08, 0x67, 0x09, 0x94, 0xEB, 0x26, 0xA8, 0x6B, 0xBD, 0x18, 0x34, 0x1B, 0xBB, 0xBF, 0x72, 0xF7, + 0x40, 0x35, 0x48, 0x9C, 0x51, 0x2F, 0x3B, 0x55, 0xE3, 0xC0, 0x9F, 0xD8, 0xD3, 0xF3, 0x8D, 0xB1, + 0xFF, 0xA7, 0x3E, 0xDC, 0x86, 0x77, 0xD7, 0xA6, 0x11, 0xFB, 0xF4, 0xBA, 0x92, 0x91, 0x64, 0x83, + 0xF1, 0x33, 0xEF, 0xDA, 0x2C, 0xB5, 0xB2, 0x2B, 0x88, 0xD1, 0x99, 0xCB, 0x8C, 0x84, 0x1D, 0x14, + 0x81, 0x97, 0x71, 0xCA, 0x5F, 0xA3, 0x8B, 0x57, 0x3C, 0x82, 0xC4, 0x52, 0x5C, 0x1C, 0xE8, 0xA0, + 0x04, 0xB4, 0x85, 0x4A, 0xF6, 0x13, 0x54, 0xB6, 0xDF, 0x0C, 0x1A, 0x8E, 0xDE, 0xE0, 0x39, 0xFC, + 0x20, 0x9B, 0x24, 0x4E, 0xA9, 0x98, 0x9E, 0xAB, 0xF2, 0x60, 0xD0, 0x6C, 0xEA, 0xFA, 0xC7, 0xD9, + 0x00, 0xD4, 0x1F, 0x6E, 0x43, 0xBC, 0xEC, 0x53, 0x89, 0xFE, 0x7A, 0x5D, 0x49, 0xC9, 0x32, 0xC2, + 0xF9, 0x9A, 0xF8, 0x6D, 0x16, 0xDB, 0x59, 0x96, 0x44, 0xE9, 0xCD, 0xE6, 0x46, 0x42, 0x8F, 0x0A, + 0xC1, 0xCC, 0xB9, 0x65, 0xB0, 0xD2, 0xC6, 0xAC, 0x1E, 0x41, 0x62, 0x29, 0x2E, 0x0E, 0x74, 0x50, + 0x02, 0x5A, 0xC3, 0x25, 0x7B, 0x8A, 0x2A, 0x5B, 0xF0, 0x06, 0x0D, 0x47, 0x6F, 0x70, 0x9D, 0x7E, + 0x10, 0xCE, 0x12, 0x27, 0xD5, 0x4C, 0x4F, 0xD6, 0x79, 0x30, 0x68, 0x36, 0x75, 0x7D, 0xE4, 0xED, + 0x80, 0x6A, 0x90, 0x37, 0xA2, 0x5E, 0x76, 0xAA, 0xC5, 0x7F, 0x3D, 0xAF, 0xA5, 0xE5, 0x19, 0x61, + 0xFD, 0x4D, 0x7C, 0xB7, 0x0B, 0xEE, 0xAD, 0x4B, 0x22, 0xF5, 0xE7, 0x73, 0x23, 0x21, 0xC8, 0x05, + 0xE1, 0x66, 0xDD, 0xB3, 0x58, 0x69, 0x63, 0x56, 0x0F, 0xA1, 0x31, 0x95, 0x17, 0x07, 0x3A, 0x28 +); + +const LOG_TAB = Uint8Array.of( + 0x80, 0x00, 0xB0, 0x09, 0x60, 0xEF, 0xB9, 0xFD, 0x10, 0x12, 0x9F, 0xE4, 0x69, 0xBA, 0xAD, 0xF8, + 0xC0, 0x38, 0xC2, 0x65, 0x4F, 0x06, 0x94, 0xFC, 0x19, 0xDE, 0x6A, 0x1B, 0x5D, 0x4E, 0xA8, 0x82, + 0x70, 0xED, 0xE8, 0xEC, 0x72, 0xB3, 0x15, 0xC3, 0xFF, 0xAB, 0xB6, 0x47, 0x44, 0x01, 0xAC, 0x25, + 0xC9, 0xFA, 0x8E, 0x41, 0x1A, 0x21, 0xCB, 0xD3, 0x0D, 0x6E, 0xFE, 0x26, 0x58, 0xDA, 0x32, 0x0F, + 0x20, 0xA9, 0x9D, 0x84, 0x98, 0x05, 0x9C, 0xBB, 0x22, 0x8C, 0x63, 0xE7, 0xC5, 0xE1, 0x73, 0xC6, + 0xAF, 0x24, 0x5B, 0x87, 0x66, 0x27, 0xF7, 0x57, 0xF4, 0x96, 0xB1, 0xB7, 0x5C, 0x8B, 0xD5, 0x54, + 0x79, 0xDF, 0xAA, 0xF6, 0x3E, 0xA3, 0xF1, 0x11, 0xCA, 0xF5, 0xD1, 0x17, 0x7B, 0x93, 0x83, 0xBC, + 0xBD, 0x52, 0x1E, 0xEB, 0xAE, 0xCC, 0xD6, 0x35, 0x08, 0xC8, 0x8A, 0xB4, 0xE2, 0xCD, 0xBF, 0xD9, + 0xD0, 0x50, 0x59, 0x3F, 0x4D, 0x62, 0x34, 0x0A, 0x48, 0x88, 0xB5, 0x56, 0x4C, 0x2E, 0x6B, 0x9E, + 0xD2, 0x3D, 0x3C, 0x03, 0x13, 0xFB, 0x97, 0x51, 0x75, 0x4A, 0x91, 0x71, 0x23, 0xBE, 0x76, 0x2A, + 0x5F, 0xF9, 0xD4, 0x55, 0x0B, 0xDC, 0x37, 0x31, 0x16, 0x74, 0xD7, 0x77, 0xA7, 0xE6, 0x07, 0xDB, + 0xA4, 0x2F, 0x46, 0xF3, 0x61, 0x45, 0x67, 0xE3, 0x0C, 0xA2, 0x3B, 0x1C, 0x85, 0x18, 0x04, 0x1D, + 0x29, 0xA0, 0x8F, 0xB2, 0x5A, 0xD8, 0xA6, 0x7E, 0xEE, 0x8D, 0x53, 0x4B, 0xA1, 0x9A, 0xC1, 0x0E, + 0x7A, 0x49, 0xA5, 0x2C, 0x81, 0xC4, 0xC7, 0x36, 0x2B, 0x7F, 0x43, 0x95, 0x33, 0xF2, 0x6C, 0x68, + 0x6D, 0xF0, 0x02, 0x28, 0xCE, 0xDD, 0x9B, 0xEA, 0x5E, 0x99, 0x7C, 0x14, 0x86, 0xCF, 0xE5, 0x42, + 0xB8, 0x40, 0x78, 0x2D, 0x3A, 0xE9, 0x64, 0x1F, 0x92, 0x90, 0x7D, 0x39, 0x6F, 0xE0, 0x89, 0x30 +); + +const BIAS_MATRIX = [ + Uint8Array.of(0x46, 0x97, 0xB1, 0xBA, 0xA3, 0xB7, 0x10, 0x0A, 0xC5, 0x37, 0xB3, 0xC9, 0x5A, 0x28, 0xAC, 0x64), + Uint8Array.of(0xEC, 0xAB, 0xAA, 0xC6, 0x67, 0x95, 0x58, 0x0D, 0xF8, 0x9A, 0xF6, 0x6E, 0x66, 0xDC, 0x05, 0x3D), + Uint8Array.of(0x8A, 0xC3, 0xD8, 0x89, 0x6A, 0xE9, 0x36, 0x49, 0x43, 0xBF, 0xEB, 0xD4, 0x96, 0x9B, 0x68, 0xA0), + Uint8Array.of(0x5D, 0x57, 0x92, 0x1F, 0xD5, 0x71, 0x5C, 0xBB, 0x22, 0xC1, 0xBE, 0x7B, 0xBC, 0x99, 0x63, 0x94), + Uint8Array.of(0x2A, 0x61, 0xB8, 0x34, 0x32, 0x19, 0xFD, 0xFB, 0x17, 0x40, 0xE6, 0x51, 0x1D, 0x41, 0x44, 0x8F), + Uint8Array.of(0xDD, 0x04, 0x80, 0xDE, 0xE7, 0x31, 0xD6, 0x7F, 0x01, 0xA2, 0xF7, 0x39, 0xDA, 0x6F, 0x23, 0xCA), + Uint8Array.of(0x3A, 0xD0, 0x1C, 0xD1, 0x30, 0x3E, 0x12, 0xA1, 0xCD, 0x0F, 0xE0, 0xA8, 0xAF, 0x82, 0x59, 0x2C), + Uint8Array.of(0x7D, 0xAD, 0xB2, 0xEF, 0xC2, 0x87, 0xCE, 0x75, 0x06, 0x13, 0x02, 0x90, 0x4F, 0x2E, 0x72, 0x33), + Uint8Array.of(0xC0, 0x8D, 0xCF, 0xA9, 0x81, 0xE2, 0xC4, 0x27, 0x2F, 0x6C, 0x7A, 0x9F, 0x52, 0xE1, 0x15, 0x38), + Uint8Array.of(0xFC, 0x20, 0x42, 0xC7, 0x08, 0xE4, 0x09, 0x55, 0x5E, 0x8C, 0x14, 0x76, 0x60, 0xFF, 0xDF, 0xD7), + Uint8Array.of(0xFA, 0x0B, 0x21, 0x00, 0x1A, 0xF9, 0xA6, 0xB9, 0xE8, 0x9E, 0x62, 0x4C, 0xD9, 0x91, 0x50, 0xD2), + Uint8Array.of(0x18, 0xB4, 0x07, 0x84, 0xEA, 0x5B, 0xA4, 0xC8, 0x0E, 0xCB, 0x48, 0x69, 0x4B, 0x4E, 0x9C, 0x35), + Uint8Array.of(0x45, 0x4D, 0x54, 0xE5, 0x25, 0x3C, 0x0C, 0x4A, 0x8B, 0x3F, 0xCC, 0xA7, 0xDB, 0x6B, 0xAE, 0xF4), + Uint8Array.of(0x2D, 0xF3, 0x7C, 0x6D, 0x9D, 0xB5, 0x26, 0x74, 0xF2, 0x93, 0x53, 0xB0, 0xF0, 0x11, 0xED, 0x83), + Uint8Array.of(0xB6, 0x03, 0x16, 0x73, 0x3B, 0x1E, 0x8E, 0x70, 0xBD, 0x86, 0x1B, 0x47, 0x7E, 0x24, 0x56, 0xF1), + Uint8Array.of(0x88, 0x46, 0x97, 0xB1, 0xBA, 0xA3, 0xB7, 0x10, 0x0A, 0xC5, 0x37, 0xB3, 0xC9, 0x5A, 0x28, 0xAC), +]; + +/* eslint-enable max-len */ + +function keySchedule(keyInit) { + const k = new Uint8Array(keyInit); + k[15] ^= 6; + const keys = [k]; + + const register = Array.from(k); + const xor = register.reduce((acc, b) => acc ^ b, 0) & 0xFF; + register.push(xor); + + for (let round = 1; round <= 16; round++) { + for (let idx = 0; idx < 17; idx++) { + const v = register[idx]; + register[idx] = (v >>> 5 | v << 3 & 0xFF) & 0xFF; + } + const keyI = new Uint8Array(16); + for (let i = 0; i < 16; i++) + keyI[i] = register[(round + i) % 17] + BIAS_MATRIX[round - 1][i] & 0xFF; + + keys.push(keyI); + } + + return keys; +} + + +function encrypt(plaintext, keys) { + const ct = Uint8Array.from(plaintext); + + for (let round = 0; round < 8; round++) { + if (round === 2) { + for (let i = 0; i < BLOCK; i++) { + if (PATTERN >> i & 1) + ct[i] ^= plaintext[i]; + else + ct[i] = ct[i] + plaintext[i] & 0xFF; + } + } + + for (let i = 0; i < BLOCK; i++) { + if (PATTERN >> i & 1) + ct[i] ^= keys[round * 2][i]; + else + ct[i] = ct[i] + keys[round * 2][i] & 0xFF; + } + + for (let i = 0; i < BLOCK; i++) { + const b = ct[i]; + ct[i] = PATTERN >> i & 1 ? EXP_TAB[b] : LOG_TAB[b]; + } + + for (let i = 0; i < BLOCK; i++) { + if (PATTERN >> i & 1) + ct[i] = keys[round * 2 + 1][i] + ct[i] & 0xFF; + else + ct[i] ^= keys[round * 2 + 1][i]; + } + + const copy = Uint8Array.from(ct); + for (let i = 0; i < BLOCK; i++) { + let sum = 0; + for (let j = 0; j < BLOCK; j++) + sum = sum + COEFFICIENTS[i][j] * copy[j] & 0xFF; + + ct[i] = sum; + } + } + + for (let i = 0; i < BLOCK; i++) { + if (PATTERN >> i & 1) + ct[i] ^= keys[16][i]; + else + ct[i] = ct[i] + keys[16][i] & 0xFF; + } + + return ct; +} + +export function getRandomChallenge() { + const file = Gio.File.new_for_path('/dev/urandom'); + const stream = file.read(null); + const bytes = stream?.read_bytes(BLOCK, null); + const data = bytes?.get_data(); + return new Uint8Array(data); +} + +export function computeChallengeResponse(challenge) { + const keys = keySchedule(challenge); + return encrypt(SEQ, keys); +} + + + diff --git a/bbm/lib/devices/redmiBuds/redmiBudsConfig.js b/bbm/lib/devices/redmiBuds/redmiBudsConfig.js new file mode 100644 index 0000000000..33d44f379c --- /dev/null +++ b/bbm/lib/devices/redmiBuds/redmiBudsConfig.js @@ -0,0 +1,117 @@ +'use strict'; + +import FlipBudsPro from './deviceConfigs/FlipBudsPro.js'; +import RedmiAirDots3Pro from './deviceConfigs/RedmiAirDots3Pro.js'; +import RedmiBuds3 from './deviceConfigs/RedmiBuds3.js'; +import RedmiBuds3Pro from './deviceConfigs/RedmiBuds3Pro.js'; +import RedmiBuds4 from './deviceConfigs/RedmiBuds4.js'; +import RedmiBuds4Active from './deviceConfigs/RedmiBuds4Active.js'; +import RedmiBuds4Pro from './deviceConfigs/RedmiBuds4Pro.js'; +import RedmiBuds5 from './deviceConfigs/RedmiBuds5.js'; +import RedmiBuds5Pro from './deviceConfigs/RedmiBuds5Pro.js'; +import RedmiBuds6 from './deviceConfigs/RedmiBuds6.js'; +import RedmiBuds6Active from './deviceConfigs/RedmiBuds6Active.js'; +import RedmiBuds6Lite from './deviceConfigs/RedmiBuds6Lite.js'; +import RedmiBuds6Play from './deviceConfigs/RedmiBuds6Play.js'; +import RedmiBuds6Pro from './deviceConfigs/RedmiBuds6Pro.js'; +import RedmiBuds6s from './deviceConfigs/RedmiBuds6s.js'; +import RedmiBuds7s from './deviceConfigs/RedmiBuds7s.js'; +import RedmiBuds8 from './deviceConfigs/RedmiBuds8.js'; +import RedmiBuds8Active from './deviceConfigs/RedmiBuds8Active.js'; +import RedmiBuds8Lite from './deviceConfigs/RedmiBuds8Lite.js'; +import RedmiBuds8Pro from './deviceConfigs/RedmiBuds8Pro.js'; +import RedmiBudsSE from './deviceConfigs/RedmiBudsSE.js'; +import XiaomiAirSE from './deviceConfigs/XiaomiAirSE.js'; +import XiaomiBuds3 from './deviceConfigs/XiaomiBuds3.js'; +import XiaomiBuds3Pro from './deviceConfigs/XiaomiBuds3Pro.js'; +import XiaomiBuds3TPro from './deviceConfigs/XiaomiBuds3TPro.js'; +import XiaomiBuds4 from './deviceConfigs/XiaomiBuds4.js'; +import XiaomiBuds4Pro from './deviceConfigs/XiaomiBuds4Pro.js'; +import XiaomiBuds5 from './deviceConfigs/XiaomiBuds5.js'; +import XiaomiBuds5Pro from './deviceConfigs/XiaomiBuds5Pro.js'; +import XiaomiBuds6 from './deviceConfigs/XiaomiBuds6.js'; +import XiaomiOpenWear from './deviceConfigs/XiaomiOpenWear.js'; +import XiaomiOpenWearPro from './deviceConfigs/XiaomiOpenWearPro.js'; + +export const RedmiBudsModelList = [ + FlipBudsPro, + RedmiAirDots3Pro, + RedmiBuds3, + RedmiBuds3Pro, + RedmiBuds4, + RedmiBuds4Active, + RedmiBuds4Pro, + RedmiBuds5, + RedmiBuds5Pro, + RedmiBuds6, + RedmiBuds6Active, + RedmiBuds6Lite, + RedmiBuds6Play, + RedmiBuds6Pro, + RedmiBuds6s, + RedmiBuds7s, + RedmiBuds8, + RedmiBuds8Active, + RedmiBuds8Lite, + RedmiBuds8Pro, + RedmiBudsSE, + XiaomiAirSE, + XiaomiBuds3, + XiaomiBuds3Pro, + XiaomiBuds3TPro, + XiaomiBuds4, + XiaomiBuds4Pro, + XiaomiBuds5, + XiaomiBuds5Pro, + XiaomiBuds6, + XiaomiOpenWear, + XiaomiOpenWearPro, +]; + +export const MessageType = { + PHONE_REQUEST: 0xC4, + RESPONSE: 0x04, + EARBUDS_REQUEST: 0xC0, + EARBUDS_RESPONSE: 0x07, + EARBUDS_NOTIFY: 0xC7, + UNKNOWN: 0xFF, +}; + +export const Opcode = { + GET_DEVICE_INFO: 0x02, + SET_DEVICE_INFO: 0x08, + GET_DEVICE_RUN_INFO: 0x09, + REPORT_STATUS: 0x0E, + AUTH_CHALLENGE: 0x50, + AUTH_CONFIRM: 0x51, + SET_CONFIG: 0xF2, + GET_CONFIG: 0xF3, + NOTIFY_CONFIG: 0xF4, + UNKNOWN: 0xFF, +}; + +export const ConfigType = { + SERIAL_NUMBER: 0x27, + GESTURES: 0x02, + AUTO_ANSWER: 0x03, + DOUBLE_CONNECTION: 0x04, + EQ_PRESET: 0x07, + RING_MY_BUDS: 0x09, + LONG_GESTURES: 0x0A, + ANC: 0x0B, + ADAPTIVE_ANC: 0x25, + LOW_LATENCY: 0x2F, + ADAPTIVE_SOUND: 0x29, + EQ_CURVE: 0x37, + PERSONALIZE_ANC: 0x3B, + ADAPTIVE_VOLUME: 0x48, + SPATIAL_AUDIO: 0x4F, + UNKNOWN: 0xFF, +}; + +export const DeviceInfoRetType = { + FIRMWARE: 0x01, + VID_PID: 0x03, + BATTERY: 0x07, +}; + diff --git a/bbm/lib/devices/redmiBuds/redmiBudsDevice.js b/bbm/lib/devices/redmiBuds/redmiBudsDevice.js new file mode 100644 index 0000000000..835d54aad5 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/redmiBudsDevice.js @@ -0,0 +1,1187 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, isArrayEqual +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {RedmiBudsSocket} from './redmiBudsSocket.js'; + +export const DeviceTypeRedmiBuds = 'redmiBuds'; + +const RedmiBudsUUID = '0000fd2d-0000-1000-8000-00805f9b34fb'; +export function isRedmiBuds(bluezDeviceProxy, uuids) { + const bluezProps = []; + const supported = uuids.includes(RedmiBudsUUID) ? 'yes' : 'no'; + return {supported, bluezProps}; +} + +export const RedmiBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_RedmiBudsDevice', +}, class RedmiBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `RedmiBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- RedmiBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._ignoreGsettingsChange = false; + + this._config = createConfig(); + this._props = createProperties(); + this._modelData = null; + this._fwVersion = ''; + this._serialNo = ''; + + this._callbacks = { + modelIntialized: this.modelIntialized.bind(this), + updateFirmware: this.updateFirmware.bind(this), + updateSerial: this.updateSerial.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateInEarState: this.updateInEarState.bind(this), + updateNoiseControl: this.updateNoiseControl.bind(this), + updateAdaptiveNC: this.updateAdaptiveNC.bind(this), + updateAncStrength: this.updateAncStrength.bind(this), + updateAmbientStrength: this.updateAmbientStrength.bind(this), + + updateGestureSingle: this.updateGestureSingle.bind(this), + updateGestureDouble: this.updateGestureDouble.bind(this), + updateGestureTriple: this.updateGestureTriple.bind(this), + updateGestureLong: this.updateGestureLong.bind(this), + updateGestureSwipe: this.updateGestureSwipe.bind(this), + + updateAutoAnswer: this.updateAutoAnswer.bind(this), + updateDualConnection: this.updateDualConnection.bind(this), + updateEqPreset: this.updateEqPreset.bind(this), + updateLongGestures: this.updateLongGestures.bind(this), + updateAdaptiveSound: this.updateAdaptiveSound.bind(this), + updateLowLatency: this.updateLowLatency.bind(this), + updateCustomEq: this.updateCustomEq.bind(this), + }; + + const profile = {type: DeviceTypeRedmiBuds, uuid: RedmiBudsUUID}; + + this._redmiBudsSocket = new RedmiBudsSocket( + this._devicePath, + profileManager, + profile, + this._callbacks + ); + } + + modelIntialized(modelData, vid, pid) { + this._modelData = modelData; + + this._log.info(`Configuration: ${JSON.stringify(this._modelData, null, 2)}`); + + this._commonIcon = this._modelData.budsIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.showSettingsButton = true; + + if (this._modelData.batteryCase) + this._caseIcon = `${this._modelData.case}`; + + this._createDefaultSettings(vid, pid); + + const devicesList = this._settings.get_strv('redmi-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'redmi-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorRedmiBudsListGsettings(); + this._updateIcons(); + this._setupAncConfig(); + + if (this._modelData.ring) { + this._ringState = 'stopped'; + this._settingsItems['ring-state'] = this._ringState; + this._ringStateLeft = 'stopped'; + this._settingsItems['ring-state-left'] = this._ringStateLeft; + this._updateGsettings(); + } + } + + _createDefaultSettings(vid, pid) { + const getDefaultAction = gestureType => { + const actions = this._modelData.gestureOptions.gestures?.[gestureType].actions ?? {}; + const values = Object.values(actions); + return values[0] ?? 0; + }; + + this._defaultsDeviceSettings = { + path: this._devicePath, + vid, + pid, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': this._fwVersion, + 'serial': this._serialNo, + + ...this._modelData.batteryCase && { + 'case': this._caseIcon, + }, + + ...this._modelData.eqPreset && { + 'eq-preset': Object.values(this._modelData.eqPreset)[0], + }, + + ...this._modelData.eqPreset?.custom !== undefined && { + 'eq-custom': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + + ...this._modelData.adaptiveSound && { + 'adapt-sound': false, + }, + + ...this._modelData.dualConnection && { + 'dual-conn': false, + }, + + ...this._modelData.autoAnswer && { + 'auto-answer': false, + }, + + ...this._modelData.lowLatencyMode && { + 'low-latency': false, + }, + + ...this._modelData.gestureOptions?.gestureTypes?.single !== undefined && { + 'single-left': getDefaultAction('single'), + 'single-right': getDefaultAction('single'), + }, + + ...this._modelData.gestureOptions?.gestureTypes?.double !== undefined && { + 'double-left': getDefaultAction('double'), + 'double-right': getDefaultAction('double'), + }, + + ...this._modelData.gestureOptions?.gestureTypes?.triple !== undefined && { + 'triple-left': getDefaultAction('triple'), + 'triple-right': getDefaultAction('triple'), + }, + + ...this._modelData.gestureOptions?.gestureTypes?.['action-hold'] !== undefined && { + 'action-hold-left': getDefaultAction('action-hold'), + 'action-hold-right': getDefaultAction('action-hold'), + }, + + ...this._modelData.gestureOptions?.gestureTypes?.['swipe'] !== undefined && { + 'swipe-left': getDefaultAction('swipe'), + 'swipe-right': getDefaultAction('swipe'), + }, + + ...this._modelData.gestureOptions?.noiseControlModes && { + 'left-longpress': 7, + 'right-longpress': 7, + }, + + ...this._modelData.ring && { + 'ring-state': 'stopped', + 'ring-state-left': 'stopped', + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('redmi-buds-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('redmi-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._modelData.batteryCase) + this._caseIcon = this._settingsItems['case']; + + if (this._modelData.eqPreset) + this._eqPreset = this._settingsItems['eq-preset']; + + if (this._modelData.eqPreset?.custom !== undefined) + this._customEq = this._settingsItems['eq-custom']; + + if (this._modelData.adaptiveSound) + this._adaptiveSound = this._settingsItems['adapt-sound']; + + if (this._modelData.dualConnection) + this._dualConn = this._settingsItems['dual-conn']; + + if (this._modelData.autoAnswer) + this._autoAnswer = this._settingsItems['auto-answer']; + + if (this._modelData.lowLatencyMode) + this._lowLatency = this._settingsItems['low-latency']; + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + + if (gestureTypes?.single !== undefined) { + this._gestureSingleLeft = this._settingsItems['single-left']; + this._gestureSingleRight = this._settingsItems['single-right']; + } + + if (gestureTypes?.double !== undefined) { + this._gestureDoubleLeft = this._settingsItems['double-left']; + this._gestureDoubleRight = this._settingsItems['double-right']; + } + + if (gestureTypes?.triple !== undefined) { + this._gestureTripleLeft = this._settingsItems['triple-left']; + this._gestureTripleRight = this._settingsItems['triple-right']; + } + + if (gestureTypes?.['action-hold'] !== undefined) { + this._gestureLongLeft = this._settingsItems['action-hold-left']; + this._gestureLongRight = this._settingsItems['action-hold-right']; + } + + if (gestureTypes?.['swipe'] !== undefined) { + this._gestureSwipeLeft = this._settingsItems['swipe-left']; + this._gestureSwipeRight = this._settingsItems['swipe-right']; + } + + if (this._modelData.gestureOptions?.noiseControlModes) { + this._leftLongpressMode = this._settingsItems['left-longpress']; + this._rightLongpressMode = this._settingsItems['right-longpress']; + } + + if (this._modelData.ring) { + this._ringState = 'stopped'; + this._ringStateLeft = 'stopped'; + } + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('redmi-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._modelData.batteryCase) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + if (this._modelData.eqPreset) { + const eqPreset = this._settingsItems['eq-preset']; + if (this._eqPreset !== eqPreset) { + this._eqPreset = eqPreset; + this._setEqPreset(eqPreset); + } + } + + if (this._modelData.eqPreset?.custom !== undefined) { + const eqCustom = this._settingsItems['eq-custom']; + if (!this._customEq || !isArrayEqual(eqCustom, this._customEq)) { + this._customEq = eqCustom; + this._setCustomEq(eqCustom); + } + } + + if (this._modelData.adaptiveSound) { + const enable = this._settingsItems['adapt-sound']; + + if (this._adaptiveSound !== enable) { + this._adaptiveSound = enable; + this._setAdaptiveSound(enable); + } + } + + if (this._modelData.lowLatencyMode) { + const enable = this._settingsItems['low-latency']; + + if (this._lowLatency !== enable) { + this._lowLatency = enable; + this._setLowLatency(enable); + } + } + + if (this._modelData.dualConnection) { + const enable = this._settingsItems['dual-conn']; + + if (this._dualConn !== enable) { + this._dualConn = enable; + this._setDualConn(enable); + } + } + + if (this._modelData.autoAnswer) { + const enable = this._settingsItems['auto-answer']; + + if (this._autoAnswer !== enable) { + this._autoAnswer = enable; + this._setAutoAnswer(enable); + } + } + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + + if (gestureTypes.single !== undefined) { + const left = this._settingsItems['single-left']; + const right = this._settingsItems['single-right']; + + if (this._gestureSingleLeft !== left) { + this._gestureSingleLeft = left; + this._setGesture('single', 'left', left); + } + + if (this._gestureSingleRight !== right) { + this._gestureSingleRight = right; + this._setGesture('single', 'right', right); + } + } + + if (gestureTypes.double !== undefined) { + const left = this._settingsItems['double-left']; + const right = this._settingsItems['double-right']; + + if (this._gestureDoubleLeft !== left) { + this._gestureDoubleLeft = left; + this._setGesture('double', 'left', left); + } + + if (this._gestureDoubleRight !== right) { + this._gestureDoubleRight = right; + this._setGesture('double', 'right', right); + } + } + + if (gestureTypes.triple !== undefined) { + const left = this._settingsItems['triple-left']; + const right = this._settingsItems['triple-right']; + + if (this._gestureTripleLeft !== left) { + this._gestureTripleLeft = left; + this._setGesture('triple', 'left', left); + } + + if (this._gestureTripleRight !== right) { + this._gestureTripleRight = right; + this._setGesture('triple', 'right', right); + } + } + + if (gestureTypes['action-hold'] !== undefined) { + const left = this._settingsItems['action-hold-left']; + const right = this._settingsItems['action-hold-right']; + + if (this._gestureLongLeft !== left) { + this._gestureLongLeft = left; + this._setGesture('action-hold', 'left', left); + } + + if (this._gestureLongRight !== right) { + this._gestureLongRight = right; + this._setGesture('action-hold', 'right', right); + } + } + + if (gestureTypes.swipe !== undefined) { + const left = this._settingsItems['swipe-left']; + const right = this._settingsItems['swipe-right']; + + if (this._gestureSwipeLeft !== left) { + this._gestureSwipeLeft = left; + this._setGesture('swipe', 'left', left); + } + + if (this._gestureSwipeRight !== right) { + this._gestureSwipeRight = right; + this._setGesture('swipe', 'right', right); + } + } + + if (this._modelData.gestureOptions?.noiseControlModes) { + const leftLongPress = this._settingsItems['left-longpress']; + if (leftLongPress !== this._leftLongpressMode) { + this._leftLongpressMode = leftLongPress; + this._setLongPressMode(true, leftLongPress); + } + + const rightLongPress = this._settingsItems['right-longpress']; + if (rightLongPress !== this._rightLongpressMode) { + this._rightLongpressMode = rightLongPress; + this._setLongPressMode(false, rightLongPress); + } + } + + if (this._modelData.ring) { + const state = this._settingsItems['ring-state']; + if (this._ringState !== state) { + this._ringState = state; + this._setRingMyBuds(state); + } + + const stateLeft = this._settingsItems['ring-state-left']; + if (this._ringStateLeft !== stateLeft) { + this._ringStateLeft = stateLeft; + this._setRingMyBuds(stateLeft, true); + } + } + } + + _monitorRedmiBudsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::redmi-buds-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('redmi-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('redmi-buds-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1ShowOnDisconnect = true; + if (this._modelData.batteryMutiple) { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery2ShowOnDisconnect = true; + this._config.battery3Icon = this._caseIcon; + } else { + this._config.battery1Icon = this._commonIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + updateFirmware(fwVersion) { + this._fwVersion = fwVersion; + if (this._settingsItems) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + } + + updateSerial(serial) { + this._serialNo = serial; + if (this._settingsItems) { + this._settingsItems['serial'] = serial; + this._updateGsettings(); + } + } + + _setupAncConfig() { + const nc = this._modelData.noiseControl; + if (!nc) + return; + + this._strengthMap = { + 'off': 0x00, + 'noiseCancellation': 0x00, + 'transparency': 0x00, + }; + + let buttonIndex = 1; + this._ancToggleMap = {}; + this._config.toggle1Title = _('Noise Control'); + + const addToggle = (type, byte, icon, name) => { + if (byte == null) + return; + + this._config[`toggle1Button${buttonIndex}Icon`] = icon; + this._config[`toggle1Button${buttonIndex}Name`] = name; + + this._ancToggleMap[buttonIndex] = {type, byte}; + + buttonIndex++; + }; + + if (nc.off != null) { + addToggle( + 'off', + nc.off, + 'bbm-anc-off-symbolic', + _('Off') + ); + } + + if (nc.transparency != null) { + addToggle( + 'transparency', + nc.transparency, + 'bbm-transperancy-symbolic', + _('Transparency') + ); + } + + if (nc.noiseCancellation != null) { + addToggle( + 'noiseCancellation', + nc.noiseCancellation, + 'bbm-anc-on-symbolic', + _('Noise Cancellation') + ); + } + + this._setupAdaptiveSwitch(); + this._setupNCStrength(); + this._setupNcLevel(); + this._setupTransparencyStrength(); + } + + _setupAdaptiveSwitch() { + if (!this._modelData.adaptiveNcSwitch) + return; + + this._config.optionsBox1.push('check-button'); + this._config.box1CheckButton = [_('Adaptive')]; + + if (this._modelData.noiseCancellationStrength || this._modelData.ancLevel) { + this._config.optionsBox2.push('check-button'); + this._config.box2CheckButton = [_('Adaptive')]; + } + } + + _setupNCStrength() { + const data = this._modelData.noiseCancellationStrength; + if (!data) + return; + + const levelNames = { + low: _('Low'), + mid: _('Mid'), + high: _('High'), + adaptive: _('Adaptive'), + }; + + const keys = Object.keys(data); + if (keys.length < 2) + return; + + const labels = []; + const map = {}; + const reverse = {}; + + keys.forEach((k, i) => { + const index = i + 1; + const byte = data[k]; + + labels.push(levelNames[k] ?? k); + map[index] = byte; + reverse[byte] = index; + }); + + this._config.box1RadioTitle = _('Noise Cancellation Strength'); + this._config.box1RadioButton = labels; + + this._noiseCancellationStrengthMap = map; + this._noiseCancellationStrengthReverse = reverse; + this._config.optionsBox1.push('radio-button'); + } + + _setupNcLevel() { + if (!this._modelData.ancLevel) + return; + + this._config.optionsBox1.push('slider'); + this._config.box1SliderTitle = _('Noise Level'); + } + + _setupTransparencyStrength() { + const data = this._modelData.transparencyStrength; + if (!data) + return; + + const transNames = { + regular: _('Regular'), + voice: _('Voice'), + ambient: _('Ambient'), + }; + + const keys = Object.keys(data); + if (keys.length < 2) + return; + + const labels = []; + const map = {}; + const reverse = {}; + + keys.forEach((k, i) => { + const index = i + 1; + const byte = data[k]; + + labels.push(transNames[k] ?? k); + map[index] = byte; + reverse[byte] = index; + }); + + this._config.box3RadioTitle = _('Transparency Strength'); + this._config.box3RadioButton = labels; + + this._transparencyStrengthMap = map; + this._transparencyStrengthReverse = reverse; + + this._config.optionsBox3.push('radio-button'); + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + if (this._modelData.noiseControl) + this._props.toggle1Visible = true; + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'box1CheckButton1State') + this._box1CheckButton1StateChanged(value); + + if (command === 'box2CheckButton1State') + this._box2CheckButton1StateChanged(value); + + if (command === 'box1RadioButtonState') + this._box1RadioButtonStateChanged(value); + + if (command === 'box3RadioButtonState') + this._box3RadioButtonStateChanged(value); + + if (command === 'box1SliderValue') + this._box1SliderValueChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateFirmwareInfo(fwVersion) { + this._settingsItems['fw-version'] = fwVersion ?? ''; + this._updateGsettings(); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + if (!this._modelData.batteryMutiple) + this._props.computedBatteryLevel = props.battery1Level; + else + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + + if (!this._battInfoRecieved) + this._startConfiguration(props); + + this.dataHandler?.setProps(this._props); + } + + updateNoiseControl(mode) { + this._log.info(`UpdateNoiseControl mode: ${hexBytes(mode)}`); + if (!this._ancToggleMap) + return; + + let toggleIndex = 0; + let modeType = null; + + for (const [index, data] of Object.entries(this._ancToggleMap)) { + if (data.byte === mode) { + toggleIndex = Number(index); + modeType = data.type; + break; + } + } + + this._props.toggle1State = toggleIndex; + const hasNCOptions = this._modelData.adaptiveANCSwitch || this._modelData.ancLevel || + this._modelData.noiseCancellationStrength; + + if (hasNCOptions && modeType === 'noiseCancellation') { + const adaptiveEnabled = this._modelData.adaptiveNcSwitch && + this._props.box1CheckButton1State; + + this._props.optionsBoxVisible = adaptiveEnabled ? 2 : 1; + } else if (this._modelData.transparencyStrength && modeType === 'transparency') { + this._props.optionsBoxVisible = 3; + } else { + this._props.optionsBoxVisible = 0; + } + + this.dataHandler?.setProps(this._props); + } + + updateAdaptiveNC(enabled) { + this._log.info(`updateAdaptiveNC enabled: ${enabled}`); + if (!this._modelData.adaptiveNcSwitch) + return; + + const state = enabled ? 1 : 0; + this._props.box1CheckButton1State = state; + this._props.box2CheckButton1State = state; + const modeIndex = this._props.toggle1State; + const toggle = this._ancToggleMap?.[modeIndex]; + if (toggle?.type === 'noiseCancellation') + this._props.optionsBoxVisible = enabled ? 2 : 1; + + this.dataHandler?.setProps(this._props); + } + + updateAncStrength(strength) { + this._log.info(`updateAncStrength strength: ${hexBytes(strength)}`); + + this._strengthMap['noiseCancellation'] = strength; + + if (this._modelData.noiseCancellationStrength) { + const index = this._noiseCancellationStrengthReverse?.[strength]; + if (index == null) + return; + + this._props.box1RadioButtonState = index; + this.dataHandler?.setProps(this._props); + } else if (this._modelData.ancLevel) { + if (this._ancLevel !== strength) { + this._ancLevel = strength; + + const level = this._modelData.ancLevel; + this._props.box1SliderValue = Math.round(strength * 100 / level); + + this.dataHandler?.setProps(this._props); + } + } + } + + updateAmbientStrength(strength) { + this._log.info(`updateAmbientStrength strength: ${hexBytes(strength)}`); + + this._strengthMap['transparency'] = strength; + const index = this._transparencyStrengthReverse?.[strength]; + + if (index == null) + return; + + this._props.box3RadioButtonState = index; + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + const toggle = this._ancToggleMap?.[index]; + if (!toggle) + return; + + this._props.toggle1State = index; + + const hasNCOptions = this._modelData.adaptiveANCSwitch || this._modelData.ancLevel || + this._modelData.noiseCancellationStrength; + + if (hasNCOptions && toggle.type === 'noiseCancellation') { + const adaptiveEnabled = this._modelData.adaptiveNcSwitch && + this._props.box1CheckButton1State; + + this._props.optionsBoxVisible = adaptiveEnabled ? 2 : 1; + } else if (this._modelData.transparencyStrength && toggle.type === 'transparency') { + this._props.optionsBoxVisible = 3; + } else { + this._props.optionsBoxVisible = 0; + } + + this.dataHandler?.setProps(this._props); + this._redmiBudsSocket?.setNoiseControl(toggle.byte, this._strengthMap[toggle.type]); + } + + _box1CheckButton1StateChanged(state) { + this.updateAdaptiveNC(state); + this._redmiBudsSocket?.setAdaptiveNC(state); + } + + _box2CheckButton1StateChanged(state) { + this.updateAdaptiveNC(state); + this._redmiBudsSocket?.setAdaptiveNC(state); + } + + _box1RadioButtonStateChanged(index) { + const strength = this._noiseCancellationStrengthMap?.[index]; + if (strength == null) + return; + + const ncByte = this._modelData.noiseControl.noiseCancellation; + this._strengthMap['noiseCancellation'] = strength; + this._redmiBudsSocket?.setNoiseControl(ncByte, strength); + } + + _box3RadioButtonStateChanged(index) { + const strength = this._transparencyStrengthMap?.[index]; + if (strength == null) + return; + + const transparencyByte = this._modelData.noiseControl.transparency; + this._strengthMap['transparency'] = strength; + this._redmiBudsSocket?.setNoiseControl(transparencyByte, strength); + } + + _box1SliderValueChanged(value) { + const ncByte = this._modelData.noiseControl.noiseCancellation; + const level = this._modelData.ancLevel; + const strength = Math.round(value * level / 100); + this._strengthMap['noiseCancellation'] = strength; + this._redmiBudsSocket?.setNoiseControl(ncByte, strength); + } + + updateInEarState(left, right) { + this._log.info(`Inear status left: ${left} Right: ${right}`); + } + + updateEqPreset(mode) { + this._log.info(`updateEqPreset : ${hexBytes(mode)}`); + if (!this._modelData.eqPreset) + return; + + if (this._eqPreset === mode) + return; + + this._eqPreset = mode; + + if (this._settingsItems) { + this._settingsItems['eq-preset'] = mode; + this._updateGsettings(); + } + } + + _setEqPreset(mode) { + this._redmiBudsSocket?.setEqPreset(mode); + } + + updateCustomEq(eqArray) { + this._log.info(`updateCustomEq : ${hexBytes(eqArray)}`); + if (this._modelData.eqPreset.custom === undefined) + return; + + if (isArrayEqual(this._customEq, eqArray)) + return; + + this._customEq = eqArray; + + if (this._settingsItems) { + this._settingsItems['eq-custom'] = eqArray; + this._updateGsettings(); + } + } + + _setCustomEq(eqArray) { + this._redmiBudsSocket?.setCustomEq(eqArray); + } + + updateAdaptiveSound(enable) { + this._log.info(`updateAdaptiveSound : ${enable}`); + if (!this._modelData.adaptiveSound) + return; + + if (this._adaptiveSound === enable) + return; + + this._adaptiveSound = enable; + + if (this._settingsItems) { + this._settingsItems['adapt-sound'] = enable; + this._updateGsettings(); + } + } + + _setAdaptiveSound(enable) { + this._redmiBudsSocket?.setAdaptiveSound(enable); + } + + + updateDualConnection(enable) { + this._log.info(`updateDualConnection : ${enable}`); + if (!this._modelData.dualConnection) + return; + + if (this._dualConn === enable) + return; + + this._dualConn = enable; + + if (this._settingsItems) { + this._settingsItems['dual-conn'] = enable; + this._updateGsettings(); + } + } + + _setDualConn(enable) { + this._redmiBudsSocket?.setDualConn(enable); + } + + updateAutoAnswer(enable) { + this._log.info(`updateAutoAnswer : ${enable}`); + if (!this._modelData.autoAnswer) + return; + + if (this._autoAnswer === enable) + return; + + this._autoAnswer = enable; + + if (this._settingsItems) { + this._settingsItems['auto-answer'] = enable; + this._updateGsettings(); + } + } + + _setAutoAnswer(enable) { + this._redmiBudsSocket?.setAutoAnswer(enable); + } + + updateLowLatency(enable) { + this._log.info(`updateLowLatency : ${enable}`); + if (!this._modelData.lowLatencyMode) + return; + + if (this._lowLatency === enable) + return; + + this._lowLatency = enable; + + if (this._settingsItems) { + this._settingsItems['low-latency'] = enable; + this._updateGsettings(); + } + } + + _setLowLatency(enable) { + this._redmiBudsSocket?.setLowLatency(enable); + } + + updateGestureSingle(left, right) { + this._log.info(`updateGestureSingle L : ${hexBytes(left)} R: ${hexBytes(right)}`); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (gestureTypes.single === undefined) + return; + + let update = false; + if (this._gestureSingleLeft !== left) { + update = true; + this._gestureSingleLeft = left; + this._settingsItems['single-left'] = left; + } + + if (this._gestureSingleRight !== right) { + update = true; + + this._gestureSingleRight = right; + this._settingsItems['single-right'] = right; + } + if (update) + this._updateGsettings(); + } + + updateGestureDouble(left, right) { + this._log.info(`updateGestureDouble L : ${hexBytes(left)} R: ${hexBytes(right)}`); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (gestureTypes.double === undefined) + return; + + let update = false; + + if (this._gestureDoubleLeft !== left) { + update = true; + this._gestureDoubleLeft = left; + this._settingsItems['double-left'] = left; + } + + if (this._gestureDoubleRight !== right) { + update = true; + this._gestureDoubleRight = right; + this._settingsItems['double-right'] = right; + } + + if (update) + this._updateGsettings(); + } + + updateGestureTriple(left, right) { + this._log.info(`updateGestureTriple L : ${hexBytes(left)} R: ${hexBytes(right)}`); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (gestureTypes.triple === undefined) + return; + + let update = false; + + if (this._gestureTripleLeft !== left) { + update = true; + this._gestureTripleLeft = left; + this._settingsItems['triple-left'] = left; + } + + if (this._gestureTripleRight !== right) { + update = true; + this._gestureTripleRight = right; + this._settingsItems['triple-right'] = right; + } + + if (update) + this._updateGsettings(); + } + + updateGestureLong(left, right) { + this._log.info(`updateGestureLong L : ${hexBytes(left)} R: ${hexBytes(right)}`); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (gestureTypes['action-hold'] === undefined) + return; + + let update = false; + + if (this._gestureLongLeft !== left) { + update = true; + this._gestureLongLeft = left; + this._settingsItems['action-hold-left'] = left; + } + + if (this._gestureLongRight !== right) { + update = true; + this._gestureLongRight = right; + this._settingsItems['action-hold-right'] = right; + } + + if (update) + this._updateGsettings(); + } + + updateGestureSwipe(left, right) { + this._log.info(`updateGestureSwipe L : ${hexBytes(left)} R: ${hexBytes(right)}`); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (gestureTypes.swipe === undefined) + return; + + let update = false; + + if (this._gestureSwipeLeft !== left) { + update = true; + this._gestureSwipeLeft = left; + this._settingsItems['swipe-left'] = left; + } + + if (this._gestureSwipeRight !== right) { + update = true; + this._gestureSwipeRight = right; + this._settingsItems['swipe-right'] = right; + } + + if (update) + this._updateGsettings(); + } + + _setGesture(type, position, value) { + this._redmiBudsSocket?.setGesture(type, position, value); + } + + updateLongGestures(leftPressMode, rightPressMode) { + this._log.info(`updateLongGestures L: ${hexBytes(leftPressMode)} + R: ${hexBytes(rightPressMode)}`); + + if (this._modelData.gestureOptions?.noiseControlModes === undefined) + return; + + let update = false; + + if (this._leftLongpressMode !== leftPressMode) { + update = true; + this._leftLongpressMode = leftPressMode; + this._settingsItems['left-longpress'] = leftPressMode; + } + + if (this._rightLongpressMode !== rightPressMode) { + update = true; + this._rightLongpressMode = rightPressMode; + this._settingsItems['right-longpress'] = rightPressMode; + } + + if (update) + this._updateGsettings(); + } + + _setLongPressMode(isleft, mode) { + this._redmiBudsSocket?.setLongPressMode(isleft, mode); + } + + _setRingMyBuds(state, isLeft = false) { + this._redmiBudsSocket?.setRingMyBuds(state, isLeft); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'redmiBuds', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + this._redmiBudsSocket?.destroy(); + this._redmiBudsSocket = null; + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + diff --git a/bbm/lib/devices/redmiBuds/redmiBudsSocket.js b/bbm/lib/devices/redmiBuds/redmiBudsSocket.js new file mode 100644 index 0000000000..1353436d10 --- /dev/null +++ b/bbm/lib/devices/redmiBuds/redmiBudsSocket.js @@ -0,0 +1,986 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {booleanFromByte, isValidByte, isArrayEqual} from '../deviceUtils.js'; +import {getRandomChallenge, computeChallengeResponse} from './redmiBudsAuthenticator.js'; +import { + RedmiBudsModelList, MessageType, Opcode, ConfigType, DeviceInfoRetType +} from './redmiBudsConfig.js'; + +/* eslint-disable max-len */ + +/** +Reference Material and Credits +https://codeberg.org/Freeyourgadget/Gadgetbridge/src/branch/master/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/redmibuds + +https://github.com/web1n/android_packages_apps_XiaomiTWS +**/ + +/* eslint-enable max-len */ + +const HEADER = [0xFE, 0xDC, 0xBA]; +const TRAILER = 0xEF; + +export const RedmiBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_RedmiSocket', +}, class RedmiBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `RedmiSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('RedmiSocket init'); + this._callbacks = callbacks; + this._seq = 0; + this._rxBuffer = []; + this._txQueue = []; + this._pendingRequest = null; + this._pendingTimeout = null; + this._allowStatusNotifyConfigRsp = false; + this._initialized = false; + + this._modelData = null; + this._challenge = []; + + this.startSocket(); + } + + _nextSeq() { + const seq = this._seq; + this._seq = this._seq + 1 & 0xFF; + return seq; + } + + postConnectInitialization() { + this._challenge = getRandomChallenge(); + const payload = [0x01, ...this._challenge]; + this._encode(MessageType.PHONE_REQUEST, Opcode.AUTH_CHALLENGE, this._nextSeq(), payload); + } + + processData(bytes) { + this._rxBuffer.push(...bytes); + + while (true) { + const msg = this._extractMessage(); + if (!msg) + break; + + this._handleMessage(msg); + } + } + + _queueRequest(type, opcode, seq, payload, loginfo = '') { + this._txQueue.push({type, opcode, seq, payload, loginfo}); + this._processQueue(); + } + + _processQueue() { + if (this._pendingRequest) + return; + + if (this._txQueue.length === 0) + return; + + const item = this._txQueue.shift(); + + if (item.loginfo) + this._log.info(item.loginfo); + + if (item.type === 0xFF && item.opcode === 0xFF) { + this._allowStatusNotifyConfigRsp = true; + return; + } + + this._encode(item.type, item.opcode, item.seq, item.payload); + this._pendingRequest = item; + + if (this._pendingTimeout) { + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + } + + this._pendingTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + this._log.info(`Response Timeout seq: ${item.seq} opcode: ${hexBytes(item.opcode)}`); + this._pendingRequest = null; + this._processQueue(); + this._pendingTimeout = null; + return GLib.SOURCE_REMOVE; + }); + } + + _completePendingRequest(msg) { + if (!this._pendingRequest) + return; + + if (msg.type !== MessageType.RESPONSE || msg.type !== MessageType.EARBUDS_RESPONSE) + return; + + const pending = this._pendingRequest; + + if (msg.seq !== pending.seq) + return; + + if (this._pendingTimeout) + GLib.source_remove(this._pendingTimeout); + + this._pendingTimeout = null; + this._pendingRequest = null; + this._processQueue(); + } + + _encode(type, opcode, seq, payload) { + const isRequest = (type & 0x40) !== 0; + const payloadLength = payload.length + (isRequest ? 1 : 2); + const out = [...HEADER, type, opcode, payloadLength >> 8 & 0xFF, payloadLength & 0xFF]; + + if (!isRequest) + out.push(0x00); + + out.push(seq & 0xFF); + out.push(...payload); + out.push(TRAILER); + + this.sendMessage(out); + } + + _extractMessage() { + const buf = this._rxBuffer; + + for (let i = 0; i <= buf.length - 8; i++) { + if (buf[i] !== HEADER[0] || buf[i + 1] !== HEADER[1] || buf[i + 2] !== HEADER[2]) + continue; + + + const payloadLength = buf[i + 5] << 8 | buf[i + 6]; + const totalLength = payloadLength + 8; + + if (i + totalLength > buf.length) { + this._log.info('Decoder: Invalid length'); + return null; + } + + if (buf[i + totalLength - 1] !== TRAILER) { + this._log.info('Decoder: No trailer byte found'); + continue; + } + + const raw = Uint8Array.from(buf.slice(i, i + totalLength)); + this._rxBuffer.splice(0, i + totalLength); + + return this._parseMessage(raw); + } + + return null; + } + + _parseMessage(raw) { + const type = raw[3]; + const opcode = raw[4]; + const isRequest = (type & 0x40) !== 0; + const seqIndex = isRequest ? 7 : 8; + const seq = raw[seqIndex]; + const payloadStart = seqIndex + 1; + const payload = raw.slice(payloadStart, raw.length - 1); + + return {type, opcode, seq, payload}; + } + + _handleMessage(msg) { + this._log.info(`Opcode: [${hexBytes(msg.opcode)}] payload: [${hexBytes(msg.payload)}]`); + switch (msg.opcode) { + case Opcode.AUTH_CHALLENGE: + case Opcode.AUTH_CONFIRM: + this._handleAuthentication(msg); + break; + + case Opcode.GET_DEVICE_INFO: + this._log.info('Device info received'); + this._decodeDeviceInfo(msg.payload); + break; + + case Opcode.GET_DEVICE_RUN_INFO: + this._log.info('Runtime info received'); + this._decodeDeviceRunInfo(msg.payload); + break; + + case Opcode.REPORT_STATUS: + this._log.info('Report Status received'); + this._decodeDeviceUpdate(msg.payload); + if (this._allowStatusNotifyConfigRsp) + this._encode(MessageType.RESPONSE, Opcode.REPORT_STATUS, msg.seq, []); + + break; + + case Opcode.GET_CONFIG: + this._log.info('Get Config received'); + this._decodeGetConfig(msg.payload); + break; + + case Opcode.NOTIFY_CONFIG: + this._log.info('Notify Config received'); + this._decodeNotifyConfig(msg.payload); + if (this._allowStatusNotifyConfigRsp) + this._encode(MessageType.RESPONSE, Opcode.NOTIFY_CONFIG, msg.seq, []); + + break; + + default: + this._log.info(`Unhandled opcode 0x${msg.opcode.toString(16)}`); + } + this._completePendingRequest(msg); + } + + _handleAuthentication(msg) { + switch (msg.opcode) { + case Opcode.AUTH_CHALLENGE: { + if (msg.type === MessageType.RESPONSE || + msg.type === MessageType.EARBUDS_RESPONSE) { + this._log.info('Received auth challenge response'); + + if (this._challenge.length > 0) { + const response = msg.payload.slice(1); + const expectedResponse = computeChallengeResponse(this._challenge); + const valid = isArrayEqual(response, expectedResponse); + + if (valid) { + this._log.info('Authentication Validated'); + this._challenge = []; + this._encode(MessageType.PHONE_REQUEST, + Opcode.AUTH_CONFIRM, this._nextSeq(), [0x01, 0x00]); + } else { + this._log.info('Authentication Not Valid'); + } + } + } else { + this._challenge = []; + this._log.info('Earbuds requested auth challenge response'); + const challenge = msg.payload.slice(1); + const response = computeChallengeResponse(challenge); + const payload = [0x01, ...response]; + this._encode(MessageType.RESPONSE, Opcode.AUTH_CHALLENGE, msg.seq, payload); + } + break; + } + case Opcode.AUTH_CONFIRM: { + this._challenge = []; + if (msg.type === MessageType.RESPONSE) { + this._log.info('Auth confirm acknowledged'); + } else { + this._log.info('Authentication completed'); + + this._encode(MessageType.RESPONSE, Opcode.AUTH_CONFIRM, + msg.seq, Uint8Array.from([0x01])); + + this._getInfo(); + } + break; + } + } + } + + _getInfo() { + this._requestDeviceInfo(); + + let retries = 0; + + this._infoTimeout = GLib.timeout_add( + GLib.PRIORITY_DEFAULT, + 300, + () => { + if (this._initialized) { + this._infoTimeout = null; + return GLib.SOURCE_REMOVE; + } + + if (retries < 4) { + this._requestDeviceInfo(); + retries++; + return GLib.SOURCE_CONTINUE; + } + + this._log.info('No DeviceInfo response'); + this._infoTimeout = null; + return GLib.SOURCE_REMOVE; + } + ); + } + + _requestDeviceInfo() { + const loginfo = 'Get Device Info'; + this._queueRequest(MessageType.PHONE_REQUEST, Opcode.GET_DEVICE_INFO, + this._nextSeq(), [0xFF, 0xFF, 0xFF, 0xFF], loginfo); + } + + _requestDeviceRunInfo() { + const loginfo = 'Get Device Run Info'; + this._queueRequest(MessageType.PHONE_REQUEST, Opcode.GET_DEVICE_RUN_INFO, + this._nextSeq(), [0xFF, 0xFF, 0xFF, 0xFF], loginfo); + } + + _sendInitializationRequests() { + this._requestDeviceRunInfo(); + + this._getSerial(); + + if (this._modelData.gestureOptions) + this._getGestures(); + + if (this._modelData.autoAnswer) + this._getAutoAnswer(); + + if (this._modelData.dualConnection) + this._getDualConnection(); + + if (this._modelData.eqPreset) + this._getEqPreset(); + + if (this._modelData.gestureOptions?.noiseControlModes) + this._getLongPressMode(); + + if (this._modelData.noiseCancellationStrength || + this._modelData.transparencyStrength || + this._modelData.ancLevel) + this._getNoiseControl(); + + if (this._modelData.adaptiveNcSwitch) + this._getAdaptiveNC(); + + if (this._modelData.adaptiveSound) + this._getAdaptiveSound(); + + if (this._modelData.lowLatencyMode) + this._getLowLatency(); + + if (this._modelData.eqPreset?.custom) + this._getCustomEq(); + + this._queueRequest(0xFF, 0xFF, 0xFF, [], 'Init Query Complete'); + } + + _parseBatteryInfo(leftBatByte, rightBatByte, caseBatByte) { + const props = {}; + + const parse = (batteryInfo, index) => { + if (batteryInfo === 0xFF) { + props[`battery${index}Level`] = 0; + props[`battery${index}Status`] = 'disconnected'; + return; + } + + const level = batteryInfo & 0x7F; + const charging = (batteryInfo & 0x80) !== 0; + + const statusStr = charging ? 'charging' : 'discharging'; + + this._log.info(`Battery ${index}: ${level} : ${statusStr}`); + + props[`battery${index}Level`] = level; + props[`battery${index}Status`] = statusStr; + }; + + parse(leftBatByte, 1); + parse(rightBatByte, 2); + parse(caseBatByte, 3); + + this._callbacks?.updateBatteryProps?.(props); + } + + _parseFirmware(fw) { + if (fw.length !== 4) + return; + + const fwVersion1 = `${fw[0] >> 4 & 0xF}.${fw[0] & 0xF}.${fw[1] >> 4 & 0xF}.${fw[1] & 0xF}`; + const fwVersion2 = `${fw[2] >> 4 & 0xF}.${fw[2] & 0xF}.${fw[3] >> 4 & 0xF}.${fw[3] & 0xF}`; + this._log.info(`Firmware Version 1 = ${fwVersion1}, Firmware Version 2 = ${fwVersion2}`); + + this._callbacks?.updateFirmware?.(fwVersion1); + } + + _parseVidPID(vidPid) { + if (vidPid.length !== 4) + return; + + if (this._initialized) + return; + + this._initialized = true; + + const vid = vidPid[0] << 8 | vidPid[1]; + const pid = vidPid[2] << 8 | vidPid[3]; + + this._log.info(`VID: ${hexBytes(vid)}, PID: ${hexBytes(pid)}`); + + this._modelData = RedmiBudsModelList.find(model => model.id.vid.includes(vid) && + model.id.pid.includes(pid)); + + if (!this._modelData) { + this._log.info(`No model matched for VID: ${hexBytes(vid)} PID: ${hexBytes(pid)}`); + return; + } + + this._callbacks?.modelIntialized?.(this._modelData, vid, pid); + this._sendInitializationRequests(); + } + + _decodeDeviceInfo(payload) { + let i = 0; + while (i < payload.length) { + const len = payload[i]; + if (i + 1 >= payload.length) + break; + + const index = payload[i + 1]; + + switch (index) { + case DeviceInfoRetType.FIRMWARE: { + if (i + 6 <= payload.length) + this._parseFirmware(payload.slice(i + 2, i + 6)); + break; + } + + case DeviceInfoRetType.VID_PID: { + if (i + 6 <= payload.length) + this._parseVidPID(payload.slice(i + 2, i + 6)); + break; + } + + case DeviceInfoRetType.BATTERY: { + if (i + 5 <= payload.length) + this._parseBatteryInfo(payload[i + 2], payload[i + 3], payload[i + 4]); + break; + } + } + + i += len + 1; + } + } + + _decodeDeviceRunInfo(payload) { + let i = 0; + + while (i < payload.length) { + const len = payload[i]; + if (i + 1 >= payload.length) + break; + + const index = payload[i + 1]; + switch (index) { + case 0x09: { + if (i + 3 > payload.length) + break; + + this._parseNoiseControl(payload.slice(i + 2, i + 4)); + break; + } +/* + case 0x0A: { + if (i + 2 > payload.length) + break; + + const enabled = payload[i + 2] === 0x00; + this._callbacks?.updateInEarSetting?.(enabled); + break; + } +*/ + } + + i += len + 1; + } + } + + _decodeDeviceUpdate(payload) { + let i = 0; + + while (i < payload.length) { + const len = payload[i]; + if (i + 1 >= payload.length) + break; + + const index = payload[i + 1]; + switch (index) { + case 0x00: { + if (i + 4 >= payload.length) + break; + + this._parseBatteryInfo(payload[i + 2], payload[i + 3], payload[i + 4]); + break; + } + + case 0x04: { + if (i + 3 >= payload.length) + break; + + this._parseNoiseControl(payload.slice(i + 2, i + 4)); + break; + } + } + + i += len + 1; + } + } + + _decodeNotifyConfig(payload) { + let i = 0; + + while (i < payload.length) { + const len = payload[i]; + if (i + 2 >= payload.length) + break; + + const index = payload[i + 2]; + switch (index) { + case 0x0C: { + if (i + 3 >= payload.length) + break; + + const state = payload[i + 3]; + const left = (state & 0x08) !== 0; + const right = (state & 0x04) !== 0; + this._callbacks?.updateInEarState?.(left, right); + break; + } + + case 0x0B: { + if (i + 4 >= payload.length) + break; + + this._parseNoiseControl(payload.slice(i + 3, i + 5)); + break; + } + } + + i += len + 1; + } + } + + _decodeGetConfig(payload) { + if (payload.length < 4) + return; + + const config = payload[2]; + const data = payload.slice(3); + switch (config) { + case ConfigType.SERIAL_NUMBER: + this._parseSerial(data); + break; + + case ConfigType.GESTURES: + this._parseGestures(data); + break; + + case ConfigType.AUTO_ANSWER: + if (this._modelData.autoAnswer) + this._parseAutoAnswer(data); + break; + + case ConfigType.DOUBLE_CONNECTION: + if (this._modelData.dualConnection) + this._parseDualConnection(data); + break; + + case ConfigType.EQ_PRESET: + if (this._modelData.eqPreset) + this._parseEqPreset(data); + break; + + case ConfigType.LONG_GESTURES: + if (this._modelData.gestureOptions?.noiseControlModes) + this._parseLongPressMode(data); + break; + + case ConfigType.ANC: + if (this._modelData.noiseControl) + this._parseNoiseControl(data); + break; + + case ConfigType.ADAPTIVE_ANC: + if (this._modelData.adaptiveNcSwitch) + this._parseAdaptiveNC(data); + break; + + case ConfigType.LOW_LATENCY: + if (this._modelData.lowLatencyMode) + this._parseLowLatency(data); + break; + + case ConfigType.ADAPTIVE_SOUND: + if (this._modelData.adaptiveSound) + this._parseAdaptiveSound(data); + break; + + case ConfigType.EQ_CURVE: + if (this._modelData.eqPreset?.custom) + this._parseCustomEq(data); + break; + + default: + this._log.error(`Unhandled config: ${config.toString(16).padStart(2, '0')}`); + break; + } + } + + _getConfig(configType, loginfo) { + this._queueRequest(MessageType.PHONE_REQUEST, Opcode.GET_CONFIG, + this._nextSeq(), [0x00, configType], loginfo); + } + + _setConfig(configType, data, loginfo) { + const configData = [0x00, configType, ...data]; + const payload = [configData.length, ...configData]; + this._queueRequest(MessageType.PHONE_REQUEST, Opcode.SET_CONFIG, + this._nextSeq(), payload, loginfo); + } + + _getNoiseControl() { + this._getConfig(ConfigType.ANC, 'Get NoiseControl'); + } + + _parseNoiseControl(data) { + if (data.length < 2) + return; + + this._log.info('Parse NoiseControl'); + + const mode = data[0]; + const strength = data[1]; + this._callbacks?.updateNoiseControl?.(mode); + + const hasNCStrength = this._modelData.noiseCancellationStrength || + this._modelData.ancLevel; + + const hasAmbientStrength = this._modelData.transparencyStrength; + const ncByte = this._modelData.noiseControl.noiseCancellation; + const ambByte = this._modelData.noiseControl.transparency; + + if (mode === ncByte && hasNCStrength) + this._callbacks?.updateAncStrength?.(strength); + else if (mode === ambByte && hasAmbientStrength) + this._callbacks?.updateAmbientStrength?.(strength); + } + + setNoiseControl(mode, strength) { + const longinfo = `Set NoiseControl mode: ${mode} strength: ${strength}`; + this._setConfig(ConfigType.ANC, [mode, strength], longinfo); + } + + _getAdaptiveNC() { + this._getConfig(ConfigType.ANC, 'Get AdaptiveNC'); + } + + _parseAdaptiveNC(data) { + this._log.info('Parse AdaptiveNC'); + const enable = booleanFromByte(data[0]); + if (enable === null) + return; + + this._callbacks?.updateAdaptiveNC?.(enable); + } + + setAdaptiveNC(enabled) { + const loginfo = `Set AdaptiveNC enabled: ${enabled}`; + this._setConfig(ConfigType.ADAPTIVE_ANC, [enabled ? 0x01 : 0x00], loginfo); + } + + _getEqPreset() { + this._getConfig(ConfigType.EQ_PRESET, 'Get EqPreset'); + } + + _parseEqPreset(data) { + this._log.info('Parse EqPreset'); + const preset = this._modelData.eqPreset; + if (!preset) { + this._log.info('No config available for Eq Preset'); + return; + } + + const mode = data[0]; + + if (!isValidByte(mode, preset)) + return; + + this._callbacks?.updateEqPreset?.(mode); + } + + setEqPreset(mode) { + const loginfo = `Set EqPreset mode: ${mode}`; + this._setConfig(ConfigType.EQ_PRESET, [mode], loginfo); + } + + _getCustomEq() { + this._getConfig(ConfigType.EQ_CURVE, 'Get CustomEq'); + } + + _parseCustomEq(data) { + this._log.info('Parse CustomEq'); + if (data.length < 37) + return; + + const arr = [ + data[9], + data[12], + data[15], + data[18], + data[21], + data[24], + data[27], + data[30], + data[33], + data[36], + ].map(v => { + if (v >= 129) + return 128 - v; + + return v; + }); + + this._callbacks?.updateCustomEq?.(arr); + } + + setCustomEq(eqArray) { + const eqCurve = eqArray.map(v => { + if (v < 0) + return 128 - v; + + return v; + }); + + const data = [ + 0x05, 0x01, 0x01, 0x0A, + 0x00, 0x3E, eqCurve[0], + 0x00, 0x7D, eqCurve[1], + 0x00, 0xFA, eqCurve[2], + 0x01, 0xF4, eqCurve[3], + 0x03, 0xE8, eqCurve[4], + 0x07, 0xD0, eqCurve[5], + 0x0F, 0xA0, eqCurve[6], + 0x1F, 0x40, eqCurve[7], + 0x2E, 0xE0, eqCurve[8], + 0x3E, 0x80, eqCurve[9], + ]; + + this._setConfig(ConfigType.EQ_CURVE, data, 'Set CustomEq'); + } + + _getSerial() { + this._getConfig(ConfigType.SERIAL_NUMBER, 'Get Serial Number'); + } + + _parseSerial(payload) { + if (!payload) + return; + + const serial = new TextDecoder().decode(new Uint8Array(payload)); + this._callbacks?.updateSerial?.(serial); + } + + _getAdaptiveSound() { + this._getConfig(ConfigType.ADAPTIVE_SOUND, 'Get AdaptiveSound'); + } + + _parseAdaptiveSound(data) { + this._log.info('Parse AdaptiveSound'); + const enable = booleanFromByte(data[0]); + if (enable === null) + return; + + this._callbacks?.updateAdaptiveSound?.(enable); + } + + setAdaptiveSound(enabled) { + const loginfo = `Set AdaptiveSound enabled: ${enabled}`; + this._setConfig(ConfigType.ADAPTIVE_SOUND, [enabled ? 0x01 : 0x00], loginfo); + } + + _getDualConnection() { + this._getConfig(ConfigType.DOUBLE_CONNECTION, 'Get DualConnection'); + } + + _parseDualConnection(data) { + this._log.info('Parse DualConnection'); + const enable = booleanFromByte(data[0]); + if (enable === null) + return; + + this._callbacks?.updateDualConnection?.(enable); + } + + setDualConn(enabled) { + const loginfo = `Set DualConnection enabled: ${enabled}`; + this._setConfig(ConfigType.DOUBLE_CONNECTION, [enabled ? 0x01 : 0x00], loginfo); + } + + _getAutoAnswer() { + this._getConfig(ConfigType.AUTO_ANSWER, 'Get AutoAnswer'); + } + + _parseAutoAnswer(data) { + this._log.info('Parse AutoAnswer'); + const enable = booleanFromByte(data[0]); + if (enable === null) + return; + + this._callbacks?.updateAutoAnswer?.(enable); + } + + setAutoAnswer(enabled) { + const loginfo = `Set AutoAnswer enabled: ${enabled}`; + this._setConfig(ConfigType.AUTO_ANSWER, [enabled ? 0x01 : 0x00], loginfo); + } + + _getLowLatency() { + this._getConfig(ConfigType.LOW_LATENCY, 'Get LowLatency'); + } + + _parseLowLatency(data) { + this._log.info('Parse LowLatency'); + const enable = booleanFromByte(data[0]); + if (enable === null) + return; + + this._callbacks?.updateLowLatency?.(enable); + } + + setLowLatency(enabled) { + const loginfo = `Set LowLatency enabled: ${enabled}`; + this._setConfig(ConfigType.LOW_LATENCY, [enabled ? 0x01 : 0x00], loginfo); + } + + _getGestures() { + this._getConfig(ConfigType.GESTURES, 'Get Gestures'); + } + + _parseGestures(data) { + this._log.info('Parse Gestures'); + + const gestureTypes = this._modelData.gestureOptions?.gestureTypes; + if (!gestureTypes) + return; + + for (let i = 0; i + 2 < data.length; i += 3) { + const type = data[i]; + const left = data[i + 1]; + const right = data[i + 2]; + + if (type === 0x04) { + if (gestureTypes.single === undefined) + this._log.error('Single-click gestures not supported'); + else + this._callbacks?.updateGestureSingle?.(left, right); + + continue; + } + + if (type === 0x01) { + if (gestureTypes.double === undefined) + this._log.error('Double-click gestures not supported'); + else + this._callbacks?.updateGestureDouble?.(left, right); + + continue; + } + + if (type === 0x02) { + if (gestureTypes.triple === undefined) + this._log.error('Triple-click gestures not supported'); + else + this._callbacks?.updateGestureTriple?.(left, right); + + continue; + } + + if (type === 0x03) { + if (gestureTypes['action-hold'] === undefined) + this._log.error('Long-press gestures not supported'); + else + this._callbacks?.updateGestureLong?.(left, right); + + continue; + } + + if (type === 0x05) { + if (gestureTypes.swipe === undefined) + this._log.error('Swipe gestures not supported'); + else + this._callbacks?.updateGestureSwipe?.(left, right); + + continue; + } + + this._log.error(`Unknown gesture type: 0x${type.toString(16)}`); + } + } + + setGesture(type, position, value) { + const loginfo = `Set Gesture type: ${type}, position: ${position}, value: ${value}`; + const gestureTypes = this._modelData?.gestureOptions?.gestureTypes; + const interactionType = gestureTypes[type]; + + if (interactionType === undefined) { + this._log.error(`Unknown gesture type: ${type}`); + return; + } + + const data = [interactionType, 0xFF, 0xFF]; + if (position === 'left') + data[1] = value; + else if (position === 'right') + data[2] = value; + + this._setConfig(ConfigType.GESTURES, data, loginfo); + } + + _getLongPressMode() { + this._getConfig(ConfigType.LONG_GESTURES, 'Get LongPressMode'); + } + + _parseLongPressMode(data) { + this._log.info('Parse LongPressMode'); + if (data.length < 2) + return; + + const leftPressMode = data[0]; + const rightPressMode = data[1]; + this._callbacks?.updateLongGestures?.(leftPressMode, rightPressMode); + } + + setLongPressMode(isleft, mode) { + const loginfo = `Set LongPressMode isleft: ${isleft}, mode: ${mode}`; + const data = [0xFF, 0xFF]; + if (isleft) + data[0] = mode; + else + data[1] = mode; + + this._setConfig(ConfigType.LONG_GESTURES, data, loginfo); + } + + setRingMyBuds(state, isLeft = false) { + const loginfo = `Set RingMyBuds state: ${state}, isLeft: ${isLeft}`; + const enabled = state === 'playing'; + let mask; + + if (!enabled) + mask = 0x03; + else + mask = isLeft ? 0x01 : 0x02; + + const data = [enabled ? 0x01 : 0x00, mask]; + + this._setConfig(ConfigType.RING_MY_BUDS, data, loginfo); + } + + destroy() { + if (this._pendingTimeout) + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + + if (this._infoTimeout) + GLib.source_remove(this._infoTimeout); + this._infoTimeout = null; + + super.destroy?.(); + } +}); diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/AccentumPlusWireless.js b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumPlusWireless.js new file mode 100644 index 0000000000..fe57ffc087 --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumPlusWireless.js @@ -0,0 +1,72 @@ +'use strict'; + +export default { + name: 'Accentum Plus Wireless', + id: ['ACPAEBT'], + type: 'headband', + + batterySingle: true, + + + audioMode: { + off: 0x00, + eq: 0x01, + podcast: 0x02, + personalized: 0x03, + }, + + eq: { + displayedBand: [50, 250, 800, 3000, 8000], + band: [0, 125, 400, 1600, 6000], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [0.0, 0.0, 3.0, 3.0, -1.0], + pop: [0.0, -5.0, 0.0, 5.0, 0.0], + dance: [2.0, -3.0, -3.0, 1.0, 1.0], + hipHop: [3.0, -1.0, -4.0, 2.0, 0.0], + classical: [0.0, 0.0, 0.0, 1.0, 2.0], + movie: [0.0, 0.0, 2.0, 2.0, -1.0], + jazz: [-3.0, 0.0, 2.0, 2.0, 0.0], + }, + }, + + noiseControl: { + type: 2, + adaptive: true, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + }, + + inEarDetection: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 5, + dualConnection: true, + notifcation: true, + comfortCalls: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + ], + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/AccentumTrueWireless.js b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumTrueWireless.js new file mode 100644 index 0000000000..5d72856360 --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumTrueWireless.js @@ -0,0 +1,66 @@ +'use strict'; + +export default { + name: 'Accentum True Wireless', + id: ['ATW1'], + type: 'earbuds', + + batteryMultiple: true, + batteryCase: true, + + eq: { + displayedBand: [63, 250, 1000, 4000, 8000], + band: [0, 90, 325, 1500, 6500], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [-2.5, 0.5, 2.5, -0.5, -3.5], + pop: [0.0, -2.5, 0.0, 2.5, -1.0], + dance: [3.0, -1.0, -2.0, 0.0, 1.0], + hipHop: [2.5, 1.5, -1.5, -1.5, -1.5], + classical: [-3.5, -1.5, 0.0, 1.5, 0.5], + movie: [-3.0, -1.0, 0.0, 1.0, -4.0], + jazz: [-4.0, 0.0, 1.0, 3.0, -1.0], + }, + }, + + noiseControl: { + type: 1, + wind: { + off: 0x00, + max: 0x01, + }, + transparencyStep: 3, + }, + + reportsCodec: true, + inEarDetection: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 3, + dualConnection: true, + notifcation: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + 0x16, // Find Ringing + ], + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/AccentumWireless.js b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumWireless.js new file mode 100644 index 0000000000..3514db1ebb --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/AccentumWireless.js @@ -0,0 +1,61 @@ +'use strict'; + +export default { + name: 'Accentum Wireless', + id: ['ACAEBT'], + type: 'headband', + + batterySingle: true, + + eq: { + displayedBand: [50, 250, 800, 3000, 8000], + band: [0, 125, 400, 1600, 6000], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [0.0, 0.0, 3.0, 3.0, -1.0], + pop: [0.0, -5.0, 0.0, 5.0, 0.0], + dance: [2.0, -3.0, -3.0, 1.0, 1.0], + hipHop: [3.0, -1.0, -4.0, 2.0, 0.0], + classical: [0.0, 0.0, 0.0, 1.0, 2.0], + movie: [0.0, 0.0, 2.0, 2.0, -1.0], + jazz: [-3.0, 0.0, 2.0, 2.0, 0.0], + }, + }, + + noiseControl: { + type: 1, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + transparencyStep: 1, + }, + + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 3, + dualConnection: true, + notifcation: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + 0x16, // Find Ringing + ], + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/HDB630.js b/bbm/lib/devices/senhBuds/deviceConfigs/HDB630.js new file mode 100644 index 0000000000..23a5134117 --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/HDB630.js @@ -0,0 +1,97 @@ +'use strict'; + +export default { + name: 'HDB 630', + id: ['HDBT'], + type: 'headband', + + batterySingle: true, + + + audioMode: { + off: 0x00, + eq: 0x01, + podcast: 0x02, + personalized: 0x03, + peq: 0x04, + }, + + eq: { + displayedBand: [50, 250, 800, 3000, 8000], + band: [50, 250, 800, 3000, 8000], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [0.0, 2.0, 2.5, 1.5, -2.0], + pop: [0.0, -2.5, 0.0, 2.5, 0.0], + dance: [3.5, 2.0, -1.5, 1.5, 3.0], + hipHop: [3.0, 1.5, -1.5, 0.0, -1.5], + classical: [-2.0, -1.5, 0.0, 3.5, 4.0], + movie: [0.0, 0.0, 2.0, 2.0, -2.0], + jazz: [-3.2, 0.0, 2.2, 2.2, 0.0], + }, + }, + + peq: { + frequency: {min: 20, max: 20000, step: 1, digit: 0, scale: 'log'}, + q: { + bell: {min: 0.25, max: 8.0, step: 0.01, digit: 2, scale: 'log'}, + hsf: {min: 0.25, max: 0.71, step: 0.01, digit: 2, scale: 'log'}, + lsf: {min: 0.25, max: 0.71, step: 0.01, digit: 2, scale: 'log'}, + }, + gain: {min: -6, max: 6, step: 0.1, digit: 1, scale: 'linear'}, + maxBands: 5, + defaultBands: [50, 250, 800, 3000, 8000], + addRemoveBand: true, + filters: ['bell', 'hsf', 'lsf'], + bypassSupported: true, + preamp: {min: -12, max: 0, step: 0.1, digit: 1, scale: 'linear'}, + }, + + crossfeed: { + off: 0x02, + low: 0x00, + high: 0x01, + }, + + noiseControl: { + type: 2, + adaptive: true, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + }, + + reportsCodec: true, + inEarDetection: true, + transPause: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 5, + dualConnection: true, + notifcation: true, + comfortCalls: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + ], + + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/MomentumTrueWireless4.js b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumTrueWireless4.js new file mode 100644 index 0000000000..e4de9fabaf --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumTrueWireless4.js @@ -0,0 +1,75 @@ +'use strict'; + +export default { + name: 'Momentum True Wireless 4', + id: ['MTW4'], + type: 'earbuds', + + batteryMultiple: true, + batteryCase: true, + + audioMode: { + off: 0x00, + eq: 0x01, + podcast: 0x02, + personalized: 0x03, + }, + + eq: { + displayedBand: [63, 250, 1000, 4000, 8000], + band: [0, 90, 325, 1500, 6500], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [-2.5, 0.5, 2.5, -0.5, -3.5], + pop: [0.0, -2.5, 0.0, 2.5, -1.0], + dance: [3.0, -1.0, -2.0, 0.0, 1.0], + hipHop: [2.5, 1.5, -1.5, -1.5, -1.5], + classical: [-3.5, -1.5, 0.0, 1.5, 0.5], + movie: [-3.0, -1.0, 0.0, 1.0, -4.0], + jazz: [-4.0, 0.0, 1.0, 3.0, -1.0], + }, + }, + + noiseControl: { + type: 1, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + transparencyStep: 5, + }, + + reportsCodec: true, + inEarDetection: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 4, + dualConnection: true, + notifcation: true, + buttonActivityNotification: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + 0x16, // Find Ringing + ], + + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless4.js b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless4.js new file mode 100644 index 0000000000..c22f36c335 --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless4.js @@ -0,0 +1,73 @@ +'use strict'; + +export default { + name: 'Momentum Wireless 4', + id: ['M4AEBT'], + type: 'headband', + + batterySingle: true, + + + audioMode: { + off: 0x00, + eq: 0x01, + podcast: 0x02, + personalized: 0x03, + }, + + eq: { + displayedBand: [63, 250, 1000, 4000, 8000], + band: [0, 120, 400, 3000, 6000], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0], + rock: [0.0, 2.0, 2.5, 1.5, -2.0], + pop: [0.0, -2.5, 0.0, 2.5, 0.0], + dance: [3.5, 2.0, -1.5, 1.5, 3.0], + hipHop: [3.0, 1.5, -1.5, 0.0, -1.5], + classical: [-2.0, -1.5, 0.0, 3.5, 4.0], + movie: [0.0, 0.0, 2.0, 2.0, -2.0], + jazz: [-3.2, 0.0, 2.2, 2.2, 0.0], + }, + }, + + noiseControl: { + type: 2, + adaptive: true, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + }, + + reportsCodec: true, + inEarDetection: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 5, + dualConnection: true, + notifcation: true, + comfortCalls: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + ], + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless5.js b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless5.js new file mode 100644 index 0000000000..f9f3a9c4a9 --- /dev/null +++ b/bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless5.js @@ -0,0 +1,76 @@ +'use strict'; + +export default { + name: 'Momentum Wireless 5', + modalais: ['M5AEBT'], + type: 'headband', + + batterySingle: true, + + + audioMode: { + off: 0x00, + eq: 0x01, + podcast: 0x02, + personalized: 0x03, + peq: 0x04, + }, + + eq: { + displayedBand: [50, 100, 250, 600, 1400, 3000, 7000, 12000], + band: [50, 100, 250, 600, 1400, 3000, 7000, 12000], + range: 6, + bassBoost: true, + custom: true, + presets: { + flat: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + rock: [-2.0, 0.0, 2.2, 2.7, 2.6, 1.1, -1.0, -2.6], + pop: [0.0, -2.0, -1.8, 0.0, 1.2, 2.2, 1.0, -1.0], + dance: [3.0, 0.0, -1.5, -1.9, -1.2, 0.0, 0.5, 1.0], + hipHop: [2.5, 1.8, 0.5, -1.2, -1.5, 0.0, -1.5, -1.5], + classical: [-3.5, -2.0, -0.8, 0.0, 0.8, 1.2, 1.0, 0.5], + movie: [-3.2, -1.5, -0.6, 0.0, 0.5, 1.0, -1.0, -4.0], + jazz: [-4.0, -0.8, 0.5, 1.0, 2.0, 2.5, 1.0, -1.0], + }, + }, + + noiseControl: { + type: 2, + adaptive: true, + wind: { + off: 0x00, + max: 0x01, + auto: 0x02, + }, + }, + + reportsCodec: true, + inEarDetection: true, + transPause: true, + smartPause: true, + autoAnswer: true, + autoPowerOff: [0, 15, 30, 60], + // ring: true, + sideTone: 5, + dualConnection: true, + notifcation: true, + spatialAudio: true, + comfortCalls: true, + + registerNotification: [ + 0x00, // Core + 0x02, // Device + 0x03, // Battery + 0x04, // Audio + 0x08, // User EQ + 0x09, // Versions + 0x0A, // Management + 0x0B, // MMI + 0x0C, // Transparency + 0x0D, // ANC + ], + + albumArtIcon: 'headphone-1', + budsIcon: 'headphone-1', +}; + diff --git a/bbm/lib/devices/senhBuds/senhBudsConfig.js b/bbm/lib/devices/senhBuds/senhBudsConfig.js new file mode 100644 index 0000000000..a6604e1809 --- /dev/null +++ b/bbm/lib/devices/senhBuds/senhBudsConfig.js @@ -0,0 +1,233 @@ +'use strict'; + +import MomentumWireless5 from './deviceConfigs/MomentumWireless5.js'; +import MomentumWireless4 from './deviceConfigs/MomentumWireless4.js'; +import MomentumTrueWireless4 from './deviceConfigs/MomentumTrueWireless4.js'; +import HDB630 from './deviceConfigs/HDB630.js'; +import AccentumWireless from './deviceConfigs/AccentumWireless.js'; +import AccentumTrueWireless from './deviceConfigs/AccentumTrueWireless.js'; +import AccentumPlusWireless from './deviceConfigs/AccentumPlusWireless.js'; + +export const SenhBudsModelList = [ + MomentumWireless5, + MomentumWireless4, + MomentumTrueWireless4, + HDB630, + AccentumWireless, + AccentumTrueWireless, + AccentumPlusWireless, +]; + +export const VendorType = { + QCOM: 0x001D, + SENH: 0x0495, +}; + +export const QcomCommandType = { + SERIAL_GET: 0x0003, + SERIAL_RET: 0x0103, +}; + +export const CommandType = { + MODELID_GET: 0x1206, + MODELID_RET: 0x1306, + + REGISTER_NOTI_SET: 0x0007, + REGISTER_NOTI_RET: 0x0107, + + FIRMWARE_GET: 0x1202, + FIRMWARE_RET: 0x1302, + + BATT_LEVEL_GET: 0x0603, + BATT_LEVEL_RET: 0x0703, + BATT_LEVEL_NOTI: 0x0683, + + BATT_STATUS_GET: 0x0602, + BATT_STATUS_RET: 0x0702, + BATT_STATUS_NOTI: 0x0682, + + INEAR_STATE_GET: 0x0402, + INEAR_STATE_RET: 0x0502, + INEAR_STATE_NOTI: 0x0482, + + ANC_STATUS_GET: 0x1A05, + ANC_STATUS_SET: 0x1A04, + ANC_STATUS_RET: 0x1B05, + ANC_STATUS_RET2: 0x1B04, + ANC_STATUS_NOTI: 0x1A85, + + ANC_MODE_GET: 0x1A01, + ANC_MODE_SET: 0x1A00, + ANC_MODE_RET: 0x1B01, + ANC_MODE_RET2: 0x1B00, + ANC_MODE_NOTI: 0x1A81, + + ANC_TRANSP_LEVEL_GET: 0x1A03, + ANC_TRANSP_LEVEL_SET: 0x1A02, + ANC_TRANSP_LEVEL_RET: 0x1B03, + ANC_TRANSP_LEVEL_RET2: 0x1B02, + ANC_TRANSP_LEVEL_NOTI: 0x1A83, + + TRANSP_STATE_GET: 0x1805, + TRANSP_STATE_SET: 0x1804, + TRANSP_STATE_RET: 0x1905, + TRANSP_STATE_RET2: 0x1904, + TRANSP_STATE_NOTI: 0x1885, + + TRANSP_LEVEL_GET: 0x1803, + TRANSP_LEVEL_SET: 0x1802, + TRANSP_LEVEL_RET: 0x1903, + TRANSP_LEVEL_RET2: 0x1902, + TRANSP_LEVEL_NOTI: 0x1883, + + AUDIO_MODE_GET: 0x0804, + AUDIO_MODE_SET: 0x0803, + AUDIO_MODE_RET: 0x0904, + AUDIO_MODE_RET2: 0x0903, + AUDIO_MODE_NOTI: 0x0884, + + BASS_BOOST_GET: 0x1009, + BASS_BOOST_SET: 0x1008, + BASS_BOOST_RET: 0x1108, + BASS_BOOST_RET2: 0x1109, + BASS_BOOST_NOTI: 0x1089, + + CROSSFEED_GET: 0x2E01, + CROSSFEED_SET: 0x2E00, + CROSSFEED_RET: 0x2F01, + CROSSFEED_NOTI: 0x2E81, + + SIDETONE_GET: 0x0806, + SIDETONE_SET: 0x0805, + SIDETONE_RET: 0x0906, + SIDETONE_RET2: 0x0905, + + COMFORT_CALL_GET: 0x0815, + COMFORT_CALL_SET: 0x0814, + COMFORT_CALL_RET: 0x0915, + COMFORT_CALL_RET2: 0x0914, + + PAUSE_ON_TRANS_GET: 0x1801, + PAUSE_ON_TRANS_SET: 0x1800, + PAUSE_ON_TRANS_RET: 0x1900, + PAUSE_ON_TRANS_RET2: 0x1901, + + INEAR_SETTING_GET: 0x0401, + INEAR_SETTING_SET: 0x0400, + INEAR_SETTING_RET: 0x0501, + INEAR_SETTING_RET2: 0x0500, + + SMART_PAUSE_GET: 0x080D, + SMART_PAUSE_SET: 0x080C, + SMART_PAUSE_RET: 0x090D, + SMART_PAUSE_RET2: 0x090C, + + AUTO_CALL_GET: 0x080B, + AUTO_CALL_SET: 0x080A, + AUTO_CALL_RET: 0x090B, + AUTO_CALL_RET2: 0x090A, + + AUTO_POWER_OFF_GET: 0x0601, + AUTO_POWER_OFF_SET: 0x0600, + AUTO_POWER_OFF_RET: 0x0701, + AUTO_POWER_OFF_RET2: 0x0700, + + CODEC_GET: 0x0800, + CODEC_RET: 0x0900, + CODEC_NOTI: 0x0880, + + FIND_BUD_RING_SET: 0x2C02, + FIND_BUD_STOP_SET: 0x2C01, + + EQ_CONFIG_GET: 0x1000, + EQ_CONFIG_RET: 0x1100, + + EQ_BAND_GET: 0x1002, + EQ_BAND_SET: 0x1001, + EQ_BAND_RET: 0x1102, + EQ_BAND_RET2: 0x1101, + EQ_BAND_NOTI: 0x1082, + + PEQ_FREQ_GET: 0x100B, + PEQ_FREQ_SET: 0x100A, + PEQ_FREQ_RET: 0x110B, + PEQ_FREQ_RET2: 0x110A, + PEQ_FREQ_NOTI: 0x108B, + + PEQ_GAIN_GET: 0x1011, + PEQ_GAIN_SET: 0x1010, + PEQ_GAIN_RET: 0x1111, + PEQ_GAIN_RET2: 0x1110, + PEQ_GAIN_NOTI: 0x1091, + + PEQ_Q_GET: 0x100D, + PEQ_Q_SET: 0x100C, + PEQ_Q_RET: 0x110D, + PEQ_Q_RET2: 0x110C, + PEQ_Q_NOTI: 0x108D, + + PEQ_FILTER_GET: 0x100F, + PEQ_FILTER_SET: 0x100E, + PEQ_FILTER_RET: 0x110F, + PEQ_FILTER_RET2: 0x110E, + PEQ_FILTER_NOTI: 0x108F, + + PEQ_PREGAIN_GET: 0x1013, + PEQ_PREGAIN_SET: 0x1012, + PEQ_PREGAIN_RET: 0x1113, + PEQ_PREGAIN_RET2: 0x1112, + PEQ_PREGAIN_NOTI: 0x1093, + + PAIRED_DEVICELIST_GET: 0x1400, + PAIRED_DEVICELIST_RET: 0x1500, + + PAIRED_OWN_DEVICE_GET: 0x1407, + PAIRED_OWN_DEVICE_RET: 0x1507, + + PAIRED_DEVICEMAX_GET: 0x1409, + PAIRED_DEVICEMAX_RET: 0x1509, + + PAIRED_DEVICEINFO_GET: 0x1401, + PAIRED_DEVICEINFO_RET: 0x1501, + + PAIRED_DEVICE_CONNECT_SET: 0x1402, + PAIRED_DEVICE_CONNECT_RET: 0x1502, + PAIRED_DEVICE_CONNECT_ERR: 0x1582, + + PAIRED_DEVICE_DISCONNECT_SET: 0x1403, + PAIRED_DEVICE_DISCONNECT_RET: 0x1503, + PAIRED_DEVICE_DISCONNECT_ERR: 0x1583, + + PAIRED_DEVICE_REMOVE_SET: 0x1405, + PAIRED_DEVICE_REMOVE_RET: 0x1505, + PAIRED_DEVICE_REMOVE_ERR: 0x1585, + + PAIRED_DEVICE_STATUS_GET: 0x1404, + PAIRED_DEVICE_STATUS_RET: 0x1504, + PAIRED_DEVICE_STATUS_NOTI: 0x1484, + +}; + +export const CodecMap = { + 0: 'SBC', + 1: 'AAC', + 2: 'aptX', + 3: 'aptX-LL', + 4: 'MP3', + 5: 'aptX-HD', + 6: 'Faststream', + 7: 'LHDC', + 8: 'aptX adaptive', + 9: 'aptX Lossless', + 10: 'LC3', + 255: '', +}; + +export const PeqFilterType = { + 'bell': 0x0D, + 'hsf': 0x0A, + 'lsf': 0x0B, + 'lpf': 0x07, + 'hpf': 0x08, + 'bypass': 0x0E, +}; diff --git a/bbm/lib/devices/senhBuds/senhBudsDevice.js b/bbm/lib/devices/senhBuds/senhBudsDevice.js new file mode 100644 index 0000000000..fd91ad3a24 --- /dev/null +++ b/bbm/lib/devices/senhBuds/senhBudsDevice.js @@ -0,0 +1,1342 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, isArrayEqual +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {SenhBudsSocket} from './senhBudsSocket.js'; +import {CodecMap, PeqFilterType} from './senhBudsConfig.js'; +import {BtDeviceState, DeviceManagementAction} from '../commonEmuns.js'; + +export const DeviceTypeSenhBuds = 'senhBuds'; + +const SenhBudsUUID = 'a2129ff3-081b-4c45-8afe-469d9c4842ec'; +export function isSenhBuds(bluezDeviceProxy, uuids) { + const bluezProps = []; + const supported = uuids.includes(SenhBudsUUID) ? 'yes' : 'no'; + return {supported, bluezProps}; +} + +export const SenhBudsDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SenhBudsDevice', +}, class SenhBudsDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SenhBudsDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- SenhBudsDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this.updateDeviceMapCb = updateDeviceMapCb; + this._ignoreGsettingsChange = false; + this._deviceInfo = []; + this._pendingBTOperations = new Map(); + + this._config = createConfig(); + this._props = createProperties(); + this._modelData = null; + this._fwVersion = ''; + this._serialNo = ''; + + this._callbacks = { + modelIntialized: this.modelIntialized.bind(this), + updateFirmware: this.updateFirmware.bind(this), + updateSerial: this.updateSerial.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateInEarStatus: this.updateInEarStatus.bind(this), + updateNoiseControl: this.updateNoiseControl.bind(this), + updateNoiseControlMode: this.updateNoiseControlMode.bind(this), + updateAncTransparencyLevel: this.updateAncTransparencyLevel.bind(this), + updateAudioMode: this.updateAudioMode.bind(this), + updateEqBand: this.updateEqBand.bind(this), + updateBassBoost: this.updateBassBoost.bind(this), + updatePeqFreq: this.updatePeqFreq.bind(this), + updatePeqGain: this.updatePeqGain.bind(this), + updatePeqQ: this.updatePeqQ.bind(this), + updatePeqFilter: this.updatePeqFilter.bind(this), + updatePeqPreGain: this.updatePeqPreGain.bind(this), + updateCrossfeed: this.updateCrossfeed.bind(this), + updateSideTone: this.updateSideTone.bind(this), + updateComfortCall: this.updateComfortCall.bind(this), + updateTransPause: this.updateTransPause.bind(this), + updateInEarSetting: this.updateInEarSetting.bind(this), + updateSmartPause: this.updateSmartPause.bind(this), + updateAutoAnswer: this.updateAutoAnswer.bind(this), + updateAutoPowerOff: this.updateAutoPowerOff.bind(this), + updateCodec: this.updateCodec.bind(this), + updatePairedDevMax: this.updatePairedDevMax.bind(this), + updatePairedDevOwn: this.updatePairedDevOwn.bind(this), + updatePairedDevList: this.updatePairedDevList.bind(this), + updatePairedDevInfo: this.updatePairedDevInfo.bind(this), + updatePairedDevStatus: this.updatePairedDevStatus.bind(this), + updatePairedDevConnectErr: this.updatePairedDevConnectErr.bind(this), + updatePairedDevDisconnectErr: this.updatePairedDevDisconnectErr.bind(this), + updatePairedDevRemoveErr: this.updatePairedDevRemoveErr.bind(this), + }; + + const profile = {type: DeviceTypeSenhBuds, uuid: SenhBudsUUID}; + + this._senhBudsSocket = new SenhBudsSocket( + this._devicePath, + profileManager, + profile, + this._callbacks + ); + } + + modelIntialized(modelData, modelId) { + this._modelData = modelData; + + this._log.info(`Configuration: ${JSON.stringify(this._modelData, null, 2)}`); + + this._commonIcon = this._modelData.budsIcon; + this._config.battery1ShowOnDisconnect = true; + this._config.showSettingsButton = true; + + if (this._modelData.batteryCase) + this._caseIcon = `${this._modelData.case}`; + + this._createDefaultSettings(modelId); + + const devicesList = this._settings.get_strv('senh-buds-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'senh-buds-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorSenhBudsListGsettings(); + this._updateIcons(); + this._setupNoiseControlConfig(); + this._updateCodecLabel(); + + if (this._modelData.ring) { + this._ringState = 'stopped'; + this._settingsItems['ring-state'] = this._ringState; + this._updateGsettings(); + } + } + + _createDefaultSettings(modelId) { + this._defaultsDeviceSettings = { + path: this._devicePath, + modelId, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': this._fwVersion, + 'serial': this._serialNo, + + ...this._modelData.batteryCase && { + 'case': this._caseIcon, + }, + + ...this._modelData.audioMode && { + 'audio-mode': 0, + }, + + ...this._modelData.eq && { + 'eq-preset': 'flat', + }, + + ...this._modelData.eq?.displayedBand !== undefined && { + 'eq-custom': new Array(this._modelData.eq.displayedBand.length).fill(0), + }, + + ...this._modelData.eq?.bassBoost && { + 'bass-boost': false, + }, + + ...this._modelData.peq && { + 'peq-bands': this._modelData.peq.defaultBands.map(freq => ({ + freq, + gain: 0, + q: this._modelData.peq.q.bell.min, + filter: 'bell', + bypass: false, + })), + 'pre-gain': 0, + }, + + ...this._modelData.crossfeed && { + 'crossfeed': 0, + }, + + ...this._modelData.sideTone && { + 'side-tone': 0, + }, + + ...this._modelData.comfortCalls && { + 'comfort-call': false, + }, + + ...this._modelData.transPause && { + 'trans-pause': false, + }, + + ...this._modelData.inEarDetection && { + 'in-ear-setting': false, + 'wear-detection-mode': 1, + }, + + ...this._modelData.smartPause && { + 'smart-pause': false, + }, + + ...this._modelData.autoAnswer && { + 'autoanswer': false, + }, + + ...this._modelData.autoPowerOff && { + 'auto-power': 0, + }, + + ...this._modelData.ring && { + 'ring-state': 'stopped', + }, + + ...this._modelData.dualConnection && { + 'dev-mgmt': [], + 'dev-mgmt-action': {seq: 0, action: 0, id: ''}, + 'own-dev': '', + 'max-dev': 2, + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('senh-buds-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('senh-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._modelData.batteryCase) + this._caseIcon = this._settingsItems['case']; + + if (this._modelData.audioMode) + this._audioMode = this._settingsItems['audio-mode']; + + if (this._modelData.eq?.displayedBand !== undefined) + this._customEq = this._settingsItems['eq-custom']; + + if (this._modelData.eq?.bassBoost) + this._bassBoost = this._settingsItems['bass-boost']; + + if (this._modelData.crossfeed) + this._crossfeed = this._settingsItems['crossfeed']; + + if (this._modelData.peq) { + this._peqBands = this._settingsItems['peq-bands']; + this._preGain = this._settingsItems['pre-gain']; + } + + if (this._modelData.sideTone) + this._sideTone = this._settingsItems['side-tone']; + + if (this._modelData.comfortCalls) + this._comfortCall = this._settingsItems['comfort-call']; + + if (this._modelData.transPause) + this._transPause = this._settingsItems['trans-pause']; + + if (this._modelData.inEarDetection) { + this._inEarSetting = this._settingsItems['in-ear-setting']; + this._wearDetectionMode = this._settingsItems['wear-detection-mode']; + } + + if (this._modelData.smartPause) + this._smartPause = this._settingsItems['smart-pause']; + + if (this._modelData.autoAnswer) + this._autoAnswer = this._settingsItems['autoanswer']; + + if (this._modelData.autoPowerOff) + this._autoPowerOff = this._settingsItems['auto-power']; + + if (this._modelData.ring) + this._ringState = this._settingsItems['ring-state']; + + if (this._modelData.dualConnection) { + this._devMgmtAction = this._settingsItems['dev-mgmt-action']; + this._maxDevices = this._settingsItems['max-dev']; + this._ownDevice = this._settingsItems['own-dev']; + } + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('senh-buds-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._modelData.batteryCase) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + if (this._modelData.audioMode) { + const audioMode = this._settingsItems['audio-mode']; + if (this._audioMode !== audioMode) { + this._audioMode = audioMode; + this._setAudioMode(audioMode); + } + } + + if (this._modelData.eq?.displayedBand !== undefined) { + const eqCustom = this._settingsItems['eq-custom']; + if (!this._customEq || !isArrayEqual(eqCustom, this._customEq)) { + const oldGains = this._customEq; + const newGains = eqCustom; + this._customEq = eqCustom; + this._setCustomEq(oldGains, newGains); + } + } + + if (this._modelData.eq?.bassBoost) { + const bassBoost = this._settingsItems['bass-boost']; + if (this._bassBoost !== bassBoost) { + this._bassBoost = bassBoost; + this._setBassBoost(bassBoost); + } + } + + if (this._modelData.peq) { + const peqBands = this._settingsItems['peq-bands']; + + if (!isArrayEqual(peqBands, this._peqBands)) { + const oldBands = this._peqBands; + const newBands = peqBands; + + this._peqBands = peqBands; + this._setPeqBands(oldBands, newBands); + } + + const preGain = this._settingsItems['pre-gain']; + if (this._preGain !== preGain) { + this._preGain = preGain; + this._setPeqPreGain(preGain); + } + } + + if (this._modelData.crossfeed) { + const crossfeed = this._settingsItems['crossfeed']; + if (this._crossfeed !== crossfeed) { + this._crossfeed = crossfeed; + this._setCrossfeed(crossfeed); + } + } + + if (this._modelData.sideTone) { + const sideTone = this._settingsItems['side-tone']; + if (this._sideTone !== sideTone) { + this._sideTone = sideTone; + this._setSideTone(sideTone); + } + } + + if (this._modelData.comfortCalls) { + const comfortCall = this._settingsItems['comfort-call']; + if (this._comfortCall !== comfortCall) { + this._comfortCall = comfortCall; + this._setComfortCall(comfortCall); + } + } + + if (this._modelData.transPause) { + const transPause = this._settingsItems['trans-pause']; + if (this._transPause !== transPause) { + this._transPause = transPause; + this._setTransPause(transPause); + } + } + + if (this._modelData.inEarDetection) { + const inEarSetting = this._settingsItems['in-ear-setting']; + if (this._inEarSetting !== inEarSetting) { + this._inEarSetting = inEarSetting; + this._setInEarSetting(inEarSetting); + this._configureMediaController(); + } + const wearDetectionMode = this._settingsItems['wear-detection-mode']; + if (this._wearDetectionMode !== wearDetectionMode) { + this._wearDetectionMode = wearDetectionMode; + this._configureMediaController(); + } + } + + if (this._modelData.smartPause) { + const smartPause = this._settingsItems['smart-pause']; + if (this._smartPause !== smartPause) { + this._smartPause = smartPause; + this._setSmartPause(smartPause); + } + } + + if (this._modelData.autoAnswer) { + const autoAnswer = this._settingsItems['autoanswer']; + if (this._autoAnswer !== autoAnswer) { + this._autoAnswer = autoAnswer; + this._setAutoAnswer(autoAnswer); + } + } + + if (this._modelData.autoPowerOff) { + const autoPowerOff = this._settingsItems['auto-power']; + if (this._autoPowerOff !== autoPowerOff) { + this._autoPowerOff = autoPowerOff; + this._setAutoPowerOff(autoPowerOff); + } + } + + if (this._modelData.ring) { + const state = this._settingsItems['ring-state']; + if (this._ringState !== state) { + this._ringState = state; + this._setRingMyBuds(state); + } + } + + if (this._modelData.dualConnection) { + const devMgmtAction = this._settingsItems['dev-mgmt-action']; + if (this._devMgmtAction.seq !== devMgmtAction.seq) { + this._devMgmtAction = devMgmtAction; + + switch (devMgmtAction.action) { + case DeviceManagementAction.Connect: + this._connectPairedDev(devMgmtAction.id); + break; + + case DeviceManagementAction.Disconnect: + this._disconnectPairedDev(devMgmtAction.id); + break; + + case DeviceManagementAction.Remove: + this._removePairedDev(devMgmtAction.id); + break; + } + } + } + } + + _monitorSenhBudsListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::senh-buds-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('senh-buds-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('senh-buds-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _configureMediaController() { + // MediaController not available in daemon context + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1ShowOnDisconnect = true; + if (this._modelData.batteryMultiple) { + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + this._config.battery2ShowOnDisconnect = true; + this._config.battery3Icon = this._caseIcon; + } else { + this._config.battery1Icon = this._commonIcon; + } + + this.dataHandler?.setConfig(this._config); + } + + updateFirmware(fwVersion) { + this._fwVersion = fwVersion; + if (this._settingsItems) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + } + + updateSerial(serial) { + this._serialNo = serial; + if (this._settingsItems) { + this._settingsItems['serial'] = serial; + this._updateGsettings(); + } + } + + _setupNoiseControlConfig() { + if (this._modelData.noiseControl?.type === 1) + this._setupNoiseControlType1Config(); + else if (this._modelData.noiseControl?.type === 2) + this._setupNoiseControlType2Config(); + } + + _setupNoiseControlType1Config() { + this._log.info('Noise Control Type1 Not Implemented'); + } + + _setupNoiseControlType2Config() { + const data = this._modelData.noiseControl; + if (!data) + return; + + this._config.toggle1Title = _('Noise Control'); + this._props.toggle1Visible = true; + + const modes = ['off', 'nc']; + + if (data.adaptive) + modes.push('adaptive'); + + this._toggle1Modes = modes; + + const labels = { + off: _('Off'), + nc: _('Noise Cancellation'), + adaptive: _('Adaptive'), + }; + + const icons = { + off: 'bbm-anc-off-symbolic', + nc: 'bbm-anc-on-symbolic', + adaptive: 'bbm-adaptive-symbolic', + }; + + for (let i = 1; i <= 4; i++) { + this._config[`toggle1Button${i}Name`] = ''; + this._config[`toggle1Button${i}Icon`] = null; + } + + modes.forEach((mode, index) => { + const button = index + 1; + this._config[`toggle1Button${button}Name`] = labels[mode]; + this._config[`toggle1Button${button}Icon`] = icons[mode]; + }); + + this._config.optionsBox1.push('slider'); + this._config.box1SliderTitle = _('Noise Level'); + + if (data.wind) { + const labels = []; + this._windModes = []; + + const windLabels = { + off: _('Off'), + max: _('Max'), + auto: _('Auto'), + }; + + Object.keys(data.wind).forEach(mode => { + this._windModes.push(mode); + labels.push(windLabels[mode] ?? mode); + }); + + if (labels.length >= 2) { + this._config.optionsBox1.push('radio-button'); + this._config.box1RadioTitle = _('Anti Wind'); + this._config.box1RadioButton = labels; + } + } + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + this._battInfoRecieved = true; + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'box1SliderValue') + this._box1SliderValueChanged(value); + + if (command === 'box1RadioButtonState') + this._box1RadioButtonStateChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + + if (!this._modelData) + return; + + if (!this._modelData?.batteryMultiple) + this._props.computedBatteryLevel = props.battery1Level; + else + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + + this._log.info(`Battery INFO: ${JSON.stringify(props)}`); + if (!this._battInfoRecieved && + (props.battery1Level > 0 && props.battery1Status !== 'disconnected') || + props.battery2Level > 0 && props.battery2Status !== 'disconnected') + this._startConfiguration(props); + + + this.dataHandler?.setProps(this._props); + } + + updateInEarStatus(bud1Status, bud2Status) { + this._bothBudsInEar = bud1Status === 'ON_HEAD' && bud2Status === 'ON_HEAD'; + this._budInEar = bud1Status === 'ON_HEAD' || bud2Status === 'ON_HEAD'; + + if (this._wearDetectionMode !== 0) { + let playbackMode = null; + + if (this._wearDetectionMode === 1) + playbackMode = this._bothBudsInEar ? 'play' : 'pause'; + else if (this._wearDetectionMode === 2) + playbackMode = this._budInEar ? 'play' : 'pause'; + + if (playbackMode) + this._mediaController?.changeActivePlayerState(playbackMode); + } + } + + updateNoiseControl(enable) { + this._log.info(`updateNoiseControl enable: ${enable}`); + + if (!enable) { + this._props.toggle1State = this._toggle1Modes.indexOf('off') + 1; ; + this._props.optionsBoxVisible = 0; + this._currentNoiseControlMode = 'off'; + } else { + const mode = this._currentNoiseControlMode === 'adaptive' ? 'adaptive' : 'nc'; + const index = this._toggle1Modes.indexOf(mode) + 1; + this._props.toggle1State = index; + + if (mode === 'nc') + this._props.optionsBoxVisible = 1; + else + this._props.optionsBoxVisible = 0; + } + + this.dataHandler?.setProps(this._props); + } + + updateNoiseControlMode(windMode, comfortState, adaptiveState) { + this._log.info(`updateNoiseControlMode windMode: ${hexBytes(windMode)} ` + + `comfortState: ${comfortState} adaptiveState: ${adaptiveState}`); + + const offIndex = this._toggle1Modes.indexOf('off') + 1; + if (adaptiveState && this._toggle1Modes.includes('adaptive')) { + this._currentNoiseControlMode = 'adaptive'; + if (this._props.toggle1State !== offIndex) { + this._props.toggle1State = this._toggle1Modes.indexOf('adaptive') + 1; + this._props.optionsBoxVisible = 0; + } + } else if (this._currentNoiseControlMode !== 'off') { + this._currentNoiseControlMode = 'nc'; + if (this._props.toggle1State !== offIndex) { + this._props.toggle1State = this._toggle1Modes.indexOf('nc') + 1; + this._props.optionsBoxVisible = 1; + } + } + + if (this._modelData.noiseControl.wind && this._windModes) { + const windData = this._modelData.noiseControl.wind; + for (let i = 0; i < this._windModes.length; i++) { + const key = this._windModes[i]; + if (windData[key] === windMode) { + this._props.box1RadioButtonState = i + 1; + break; + } + } + } + + this.dataHandler?.setProps(this._props); + } + + updateAncTransparencyLevel(level) { + this._log.info(`updateAncTransparencyLevel level: ${level}`); + + if (this._props.box1SliderValue === level) + return; + + this._props.box1SliderValue = level; + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + const mode = this._toggle1Modes?.[index - 1]; + if (!mode) + return; + + this._currentNoiseControlMode = mode; + this._props.toggle1State = index; + + if (mode === 'off') { + this._props.optionsBoxVisible = 0; + this.dataHandler?.setProps(this._props); + this._senhBudsSocket?.setNoiseControl(false); + return; + } + + const adaptive = mode === 'adaptive'; + this._props.optionsBoxVisible = adaptive ? 0 : 1; + this.dataHandler?.setProps(this._props); + this._senhBudsSocket?.setNoiseControl(true); + this._senhBudsSocket?.setNoiseControlMode(3, adaptive); + } + + _box1SliderValueChanged(value) { + this._props.box1SliderValue = value; + this.dataHandler?.setProps(this._props); + this._senhBudsSocket?.setAncTransparencyLevel(value); + } + + _box1RadioButtonStateChanged(index) { + const key = this._windModes?.[index - 1]; + if (!key) + return; + + const value = this._modelData.noiseControl.wind[key]; + this._props.box1RadioButtonState = index; + this.dataHandler?.setProps(this._props); + this._senhBudsSocket?.setNoiseControlMode(1, value); + } + + updateAudioMode(mode) { + this._log.info(`updateAudioMode mode: ${hexBytes(mode)}`); + if (this._audioMode !== mode) { + this._audioMode = mode; + this._settingsItems['audio-mode'] = mode; + this._updateGsettings(); + } + } + + _setAudioMode(mode) { + this._senhBudsSocket?.setAudioMode(mode); + } + + updateEqBand(arr) { + let preset = 'custom'; + + for (const [name, values] of Object.entries(this._modelData.eq.presets ?? {})) { + if (isArrayEqual(arr, values)) { + preset = name; + break; + } + } + + let settingsChanged = false; + + if (this._settingsItems['eq-preset'] !== preset) { + this._settingsItems['eq-preset'] = preset; + settingsChanged = true; + } + + if (!isArrayEqual(arr, this._customEq)) { + this._customEq = arr; + this._settingsItems['eq-custom'] = arr; + settingsChanged = true; + } + + if (settingsChanged) + this._updateGsettings(); + } + + _setCustomEq(oldGains, newGains) { + for (let i = 0; i < oldGains.length; i++) { + if (oldGains[i] !== newGains[i]) + this._senhBudsSocket?.setEqBand(i, newGains[i]); + } + } + + updateBassBoost(enable) { + this._log.info(`updateBassBoost enable: ${enable}`); + if (this._bassBoost !== enable) { + this._bassBoost = enable; + this._settingsItems['bass-boost'] = enable; + this._updateGsettings(); + } + } + + _setBassBoost(enable) { + this._senhBudsSocket?.setBassBoost(enable); + } + + updatePeqFreq(arr) { + this._log.info(`updatePeqFreq: ${JSON.stringify(arr)}`); + + let changed = false; + + for (const {stage, freq} of arr) { + if (this._peqBands[stage]?.freq !== freq) { + this._peqBands[stage].freq = freq; + changed = true; + } + } + + if (changed) { + this._settingsItems['peq-bands'] = this._peqBands; + this._updateGsettings(); + } + } + + updatePeqGain(arr) { + this._log.info(`updatePeqGain: ${JSON.stringify(arr)}`); + + let changed = false; + + for (const {stage, gain} of arr) { + if (this._peqBands[stage]?.gain !== gain) { + this._peqBands[stage].gain = gain; + changed = true; + } + } + + if (changed) { + this._settingsItems['peq-bands'] = this._peqBands; + this._updateGsettings(); + } + } + + updatePeqQ(arr) { + this._log.info(`updatePeqQ: ${JSON.stringify(arr)}`); + + let changed = false; + + for (const {stage, q} of arr) { + if (this._peqBands[stage]?.q !== q) { + this._peqBands[stage].q = q; + changed = true; + } + } + + if (changed) { + this._settingsItems['peq-bands'] = this._peqBands; + this._updateGsettings(); + } + } + + updatePeqFilter(arr) { + this._log.info(`updatePeqFilter: ${JSON.stringify(arr)}`); + + let changed = false; + + for (const {stage, filter} of arr) { + const band = this._peqBands[stage]; + + if (!band) + continue; + + if (filter === PeqFilterType.bypass) { + if (!band.bypass) { + band.bypass = true; + changed = true; + } + + continue; + } + + const filterName = Object.keys(PeqFilterType) + .find(key => PeqFilterType[key] === filter); + + if (!filterName) + continue; + + if (band.bypass) { + band.bypass = false; + changed = true; + } + + if (band.filter !== filterName) { + band.filter = filterName; + changed = true; + } + } + + if (changed) { + this._settingsItems['peq-bands'] = this._peqBands; + this._updateGsettings(); + } + } + + updatePeqPreGain(level) { + this._log.info(`updatePeqPreGain level: ${level}`); + + if (this._preGain !== level) { + this._preGain = level; + this._settingsItems['pre-gain'] = level; + this._updateGsettings(); + } + } + + _setPeqBands(oldBands, newBands) { + const bandCount = Math.min(oldBands.length, newBands.length); + + for (let i = 0; i < bandCount; i++) { + const oldBand = oldBands[i]; + const newBand = newBands[i]; + + if (oldBand.freq !== newBand.freq) + this._senhBudsSocket?.setPeqFreq(i, newBand.freq); + + if (oldBand.gain !== newBand.gain) + this._senhBudsSocket?.setPeqGain(i, newBand.gain); + + if (oldBand.q !== newBand.q) + this._senhBudsSocket?.setPeqQ(i, newBand.q); + + if (oldBand.filter !== newBand.filter || oldBand.bypass !== newBand.bypass) { + let filterByte; + + if (newBand.bypass) { + filterByte = PeqFilterType.bypass; + } else { + filterByte = PeqFilterType[newBand.filter]; + + if (filterByte === undefined) { + this._log.warning( + `Unknown PEQ filter "${newBand.filter}" for band ${i}` + ); + continue; + } + } + + this._senhBudsSocket?.setPeqFilter(i, filterByte); + } + } + } + + _setPeqPreGain(gain) { + this._senhBudsSocket?.setPeqPreGain(gain); + } + + updateCrossfeed(level) { + this._log.info(`updateCrossfeed level: ${level}`); + if (this._crossfeed !== level) { + this._crossfeed = level; + this._settingsItems['crossfeed'] = level; + this._updateGsettings(); + } + } + + _setCrossfeed(level) { + this._senhBudsSocket?.setCrossfeed(level); + } + + updateSideTone(level) { + this._log.info(`updateSideTone level: ${level}`); + if (this._sideTone !== level) { + this._sideTone = level; + this._settingsItems['side-tone'] = level; + this._updateGsettings(); + } + } + + _setSideTone(level) { + this._senhBudsSocket?.setSideTone(level); + } + + updateComfortCall(enable) { + this._log.info(`updateComfortCall enable: ${enable}`); + if (this._comfortCall !== enable) { + this._comfortCall = enable; + this._settingsItems['comfort-call'] = enable; + this._updateGsettings(); + } + } + + _setComfortCall(enable) { + this._senhBudsSocket?.setComfortCall(enable); + } + + updateTransPause(enable) { + this._log.info(`updateTransPause enable: ${enable}`); + if (this._transPause !== enable) { + this._transPause = enable; + this._settingsItems['trans-pause'] = enable; + this._updateGsettings(); + } + } + + _setTransPause(enable) { + this._senhBudsSocket?.setTransPause(enable); + } + + updateInEarSetting(enable) { + this._log.info(`updateInEarSetting enable: ${enable}`); + if (this._inEarSetting !== enable) { + this._inEarSetting = enable; + this._settingsItems['in-ear-setting'] = enable; + this._updateGsettings(); + } + } + + _setInEarSetting(enable) { + this._senhBudsSocket?.setInEarSetting(enable); + } + + updateSmartPause(enable) { + this._log.info(`updateSmartPause enable: ${enable}`); + if (this._smartPause !== enable) { + this._smartPause = enable; + this._settingsItems['smart-pause'] = enable; + this._updateGsettings(); + } + } + + _setSmartPause(enable) { + this._senhBudsSocket?.setSmartPause(enable); + } + + updateAutoAnswer(enable) { + this._log.info(`updateAutoAnswer enable: ${enable}`); + if (this._autoAnswer !== enable) { + this._autoAnswer = enable; + this._settingsItems['autoanswer'] = enable; + this._updateGsettings(); + } + } + + _setAutoAnswer(enable) { + this._senhBudsSocket?.setAutoAnswer(enable); + } + + updateAutoPowerOff(time) { + this._log.info(`updateAutoPowerOff enable: ${time}`); + if (this._autoPowerOff !== time) { + this._autoPowerOff = time; + this._settingsItems['auto-power'] = time; + this._updateGsettings(); + } + } + + _setAutoPowerOff(time) { + this._senhBudsSocket?.setAutoPowerOff(time); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'senhBuds', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + _updateCodecLabel() { + if (this._modelData.reportsCodec) + this._config.labelIndicatorEnabled = 1; + } + + updateCodec(codec) { + const text = CodecMap.hasOwnProperty(codec) ? CodecMap[codec] : ''; + this._props.labelIndicator1 = text; + this.dataHandler?.setProps(this._props); + } + + updatePairedDevMax(max) { + this._log.info(`updatePairedDevMax max: ${max}`); + if (this._maxDevices !== max) { + this._maxDevices = max; + this._settingsItems['max-dev'] = max; + this._updateGsettings(); + } + } + + updatePairedDevOwn(id) { + this._log.info(`updatePairedDevOwn id: ${id}`); + if (this._ownDevice !== id) { + this._ownDevice = id; + this._settingsItems['own-dev'] = id; + this._updateGsettings(); + } + } + + updatePairedDevList(size) { + this._log.info(`updatePairedDevList size: ${size}`); + + while (this._deviceInfo.length > size) + this._deviceInfo.pop(); + + while (this._deviceInfo.length < size) { + const id = String(this._deviceInfo.length); + + this._deviceInfo.push({ + id, + name: '', + connected: false, + state: BtDeviceState.NotInitialized, + }); + } + + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + updatePairedDevInfo(id, name, connected) { + this._log.info(`updatePairedDevInfo id: ${id}, name: ${name}, connected: ${connected}`); + + this._stopBTTimeout(id); + + const device = this._deviceInfo.find(info => info.id === id); + + if (!device) + return; + + let changed = false; + + if (device.name !== name) { + device.name = name; + changed = true; + } + + if (device.connected !== connected) { + device.connected = connected; + changed = true; + } + + if (device.state !== BtDeviceState.Ready) { + device.state = BtDeviceState.Ready; + changed = true; + } + + if (changed) { + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + } + + updatePairedDevStatus(id, connected) { + this._log.info(`updatePairedDevStatus id: ${id}, connected: ${connected}`); + + const device = this._deviceInfo.find(device => device.id === id); + + if (!device) + return; + + device.connected = connected !== 0; + device.state = BtDeviceState.Ready; + + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + + this._stopBTTimeout(id); + } + + _startBTTimeout(id, operation) { + const timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { + const pending = this._pendingBTOperations.get(id); + + if (!pending) + return GLib.SOURCE_REMOVE; + + const device = this._deviceInfo.find(info => info.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._pendingBTOperations.delete(id); + + return GLib.SOURCE_REMOVE; + }); + + this._pendingBTOperations.set(id, {operation, timeoutId}); + } + + _stopBTTimeout(id) { + const pending = this._pendingBTOperations.get(id); + if (!pending) + return; + + if (pending.timeoutId) + GLib.Source.remove(pending.timeoutId); + + this._pendingBTOperations.delete(id); + } + + updatePairedDevConnectErr() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'connect'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + _connectPairedDev(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'connect'); + this._senhBudsSocket?.connectPairedDev(id); + } + + updatePairedDevDisconnectErr() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'disconnect'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + _disconnectPairedDev(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'disconnect'); + this._senhBudsSocket?.disconnectPairedDev(id); + } + + updatePairedDevRemoveErr() { + const pending = [...this._pendingBTOperations.entries()] + .find(([, pending]) => pending.operation === 'remove'); + + if (!pending) + return; + + const [id] = pending; + const device = this._deviceInfo.find(device => device.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._stopBTTimeout(id); + } + + _removePairedDev(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._startBTTimeout(id, 'remove'); + this._senhBudsSocket?.removePairedDev(id); + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + + this._senhBudsSocket?.destroy(); + this._senhBudsSocket = null; + + for (const {timeoutId} of this._pendingBTOperations.values()) + GLib.Source.remove(timeoutId); + + this._pendingBTOperations.clear(); + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + if (this._mediaHandlerId) + this._mediaController?.disconnect(this._mediaHandlerId); + this._mediaHandlerId = null; + this._mediaController?.destroy(); + this._mediaController = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); + diff --git a/bbm/lib/devices/senhBuds/senhBudsSocket.js b/bbm/lib/devices/senhBuds/senhBudsSocket.js new file mode 100644 index 0000000000..f8da7e964e --- /dev/null +++ b/bbm/lib/devices/senhBuds/senhBudsSocket.js @@ -0,0 +1,1383 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier, hexBytes} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; +import {booleanFromByte, isValidByte} from '../deviceUtils.js'; +import { + SenhBudsModelList, VendorType, CommandType, QcomCommandType +} from './senhBudsConfig.js'; + +/** +Reference Material and Credits +https://github.com/hatemosphere/hdb630-control-macos + +https://github.com/nikita36078/sennheiser-desktop-client +**/ + +const HEADER = [0xFF, 0x03]; + +export const SenhBudsSocket = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SenhSocket', +}, class SenhBudsSocket extends SocketHandler { + _init(devicePath, profileManager, profile, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SenhSocket-${identifier}`; + this._log = createLogger(tag); + this._log.info('SenhSocket init'); + this._callbacks = callbacks; + this._rxBuffer = []; + this._txQueue = []; + this._pendingRequest = null; + this._pendingTimeout = null; + this._modelData = null; + this._battInfo = { + battery1Level: 0, + battery2Level: 0, + battery3Level: 0, + battery1Status: 'disconnected', + battery2Status: 'disconnected', + battery3Status: 'disconnected', + }; + + this.startSocket(); + } + + postConnectInitialization() { + this._getModelId(); + } + + _queueRequest(vendor, command, payload, loginfo = '') { + this._txQueue.push({vendor, command, payload, loginfo}); + this._processQueue(); + } + + _processQueue() { + if (this._pendingRequest) + return; + + if (this._txQueue.length === 0) + return; + + const item = this._txQueue.shift(); + + if (item.loginfo) + this._log.info(item.loginfo); + + this._encode(item.vendor, item.command, item.payload); + this._pendingRequest = item; + + if (this._pendingTimeout) { + GLib.source_remove(this._pendingTimeout); + this._pendingTimeout = null; + } + + this._pendingTimeout = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + this._log.info(`Response Timeout type: ${hexBytes(item.command)}`); + this._pendingRequest = null; + this._processQueue(); + this._pendingTimeout = null; + return GLib.SOURCE_REMOVE; + }); + } + + _completePendingRequest(msg) { + if (!this._pendingRequest) + return; + + const pending = this._pendingRequest; + + if (msg.vendor !== pending.vendor) + return; + + const requestCommand = pending.command; + const responseCommand = requestCommand | 0x0100; + const errorCommand = requestCommand | 0x0180; + + if (msg.command !== responseCommand && msg.command !== errorCommand) + return; + + if (msg.command === errorCommand) + log(`Command 0x${requestCommand.toString(16)} failed`); + + if (this._pendingTimeout) + GLib.source_remove(this._pendingTimeout); + + this._pendingTimeout = null; + this._pendingRequest = null; + this._processQueue(); + } + + _encode(vendor, command, payload = []) { + const payloadLength = payload.length; + + const out = [ + ...HEADER, + payloadLength >> 8 & 0xFF, + payloadLength & 0xFF, + vendor >> 8 & 0xFF, + vendor & 0xFF, + command >> 8 & 0xFF, + command & 0xFF, + ...payload, + ]; + + this.sendMessage(out); + } + + processData(bytes) { + this._rxBuffer.push(...bytes); + + while (true) { + const msg = this._extractMessage(); + if (!msg) + break; + + if (msg.vendor === VendorType.QCOM) + this._handleMessageQcom(msg); + else if (msg.vendor === VendorType.SENH) + this._handleMessageSenh(msg); + else + this._log.info(`Unknown vendor command tpye ${hexBytes(msg.vendor)}`); + + this._completePendingRequest(msg); + } + } + + _extractMessage() { + const buf = this._rxBuffer; + + for (let i = 0; i <= buf.length - 8; i++) { + if (buf[i] !== HEADER[0] || buf[i + 1] !== HEADER[1]) + continue; + + const payloadLength = buf[i + 2] << 8 | buf[i + 3]; + const totalLength = payloadLength + 8; + + if (i + totalLength > buf.length) + return null; + + const raw = buf.slice(i, i + totalLength); + + let nextHeader = -1; + for (let j = 8; j < raw.length - 1; j++) { + if (raw[j] === HEADER[0] && raw[j + 1] === HEADER[1]) { + nextHeader = j; + break; + } + } + + if (nextHeader !== -1) { + this._rxBuffer.splice(0, i + nextHeader); + return this._extractMessage(); + } + + this._rxBuffer.splice(0, i + totalLength); + return this._parseMessage(raw); + } + + return null; + } + + _parseMessage(raw) { + const payloadLength = raw[2] << 8 | raw[3]; + const vendor = raw[4] << 8 | raw[5]; + const command = raw[6] << 8 | raw[7]; + const payload = raw.slice(8, 8 + payloadLength); + return {vendor, command, payload}; + } + + _handleMessageQcom(msg) { + this._log.info(`QCOM: CommandType: [${hexBytes(msg.command)}] ` + + `payload: [${hexBytes(msg.payload)}]`); + + switch (msg.command) { + case QcomCommandType.SERIAL_RET: { + this._parseSerial(msg.payload); + break; + } + + default: + this._log.info(`Unhandled QCOM command ${hexBytes(msg.command)}`); + } + } + + _handleMessageSenh(msg) { + this._log.info(`SENH: CommandType: [${hexBytes(msg.command)}] ` + + `payload: [${hexBytes(msg.payload)}]`); + + switch (msg.command) { + case CommandType.MODELID_RET: { + this._parseModelId(msg.payload); + break; + } + + case CommandType.FIRMWARE_RET: { + this._parseFirmware(msg.payload); + break; + } + + case CommandType.BATT_LEVEL_RET: + case CommandType.BATT_LEVEL_NOTI: { + this._parseBatteryLevel(msg.payload); + break; + } + + case CommandType.BATT_STATUS_RET: + case CommandType.BATT_STATUS_NOTI: { + this._parseBatteryStatus(msg.payload); + break; + } + + case CommandType.INEAR_STATE_RET: + case CommandType.INEAR_STATE_NOTI: { + this._parseInEarState(msg.payload); + break; + } + + case CommandType.ANC_STATUS_RET: + case CommandType.ANC_STATUS_RET2: + case CommandType.ANC_STATUS_NOTI: { + if (this._modelData?.noiseControl) + this._parseNoiseControl(msg.payload); + break; + } + + case CommandType.ANC_MODE_RET: + case CommandType.ANC_MODE_RET2: + case CommandType.ANC_MODE_NOTI: { + if (this._modelData?.noiseControl) + this._parseNoiseControlMode(msg.payload); + break; + } + + case CommandType.ANC_TRANSP_LEVEL_RET: + case CommandType.ANC_TRANSP_LEVEL_RET2: + case CommandType.ANC_TRANSP_LEVEL_NOTI: { + if (this._modelData?.noiseControl?.type === 2) + this._parseAncTransparencyLevel(msg.payload); + break; + } + + case CommandType.TRANSP_STATE_RET: + case CommandType.TRANSP_STATE_RET2: + case CommandType.TRANSP_STATE_NOTI: { + this._log.info('Parse Type1 Transparency State'); + break; + } + + case CommandType.TRANSP_LEVEL_RET: + case CommandType.TRANSP_LEVEL_RET2: + case CommandType.TRANSP_LEVEL_NOTI: { + this._log.info('Parse Type1 Transparency Level'); + break; + } + + case CommandType.AUDIO_MODE_RET: + case CommandType.AUDIO_MODE_RET2: + case CommandType.AUDIO_MODE_NOTI: { + if (this._modelData?.audioMode) + this._parseAudioMode(msg.payload); + break; + } + + case CommandType.EQ_CONFIG_RET: { + if (this._modelData?.eq) + this._parseEqConfig(msg.payload); + break; + } + + case CommandType.EQ_BAND_RET: + case CommandType.EQ_BAND_RET2: + case CommandType.EQ_BAND_NOTI: { + if (this._modelData?.eq) + this._parseEqBand(msg.payload); + break; + } + + case CommandType.BASS_BOOST_RET: + case CommandType.BASS_BOOST_RET2: + case CommandType.BASS_BOOST_NOTI: { + if (this._modelData?.eq?.bassBoost) + this._parseBassBoost(msg.payload); + break; + } + + case CommandType.PEQ_FREQ_RET: + case CommandType.PEQ_FREQ_RET2: + case CommandType.PEQ_FREQ_NOTI: { + if (this._modelData?.peq) + this._parsePeqFreq(msg.payload); + break; + } + + case CommandType.PEQ_GAIN_RET: + case CommandType.PEQ_GAIN_RET2: + case CommandType.PEQ_GAIN_NOTI: { + if (this._modelData?.peq) + this._parsePeqGain(msg.payload); + break; + } + + case CommandType.PEQ_Q_RET: + case CommandType.PEQ_Q_RET2: + case CommandType.PEQ_Q_NOTI: { + if (this._modelData?.peq) + this._parsePeqQ(msg.payload); + break; + } + + case CommandType.PEQ_FILTER_RET: + case CommandType.PEQ_FILTER_RET2: + case CommandType.PEQ_FILTER_NOTI: { + if (this._modelData?.peq) + this._parsePeqFilter(msg.payload); + break; + } + + case CommandType.PEQ_PREGAIN_RET: + case CommandType.PEQ_PREGAIN_RET2: { + if (this._modelData?.peq) + this._parsePeqPreGain(msg.payload); + break; + } + + case CommandType.PEQ_PREGAIN_NOTI: { + if (this._modelData?.peq) + this._parsePeqPreGainNoti(msg.payload); + break; + } + + case CommandType.CROSSFEED_RET: + case CommandType.CROSSFEED_NOTI: { + if (this._modelData?.sideTone) + this._parseCrossfeed(msg.payload); + break; + } + + case CommandType.SIDETONE_RET: + case CommandType.SIDETONE_RET2: { + if (this._modelData?.sideTone) + this._parseSideTone(msg.payload); + break; + } + + case CommandType.COMFORT_CALL_RET: + case CommandType.COMFORT_CALL_RET2: { + if (this._modelData?.comfortCalls) + this._parseComfortCall(msg.payload); + break; + } + + case CommandType.PAUSE_ON_TRANS_RET: + case CommandType.PAUSE_ON_TRANS_RET2: { + if (this._modelData?.transPause) + this._parseTransPause(msg.payload); + break; + } + + case CommandType.INEAR_SETTING_RET: + case CommandType.INEAR_SETTING_RET2: { + if (this._modelData?.inEarDetection) + this._parseInEarSetting(msg.payload); + break; + } + + case CommandType.SMART_PAUSE_RET: + case CommandType.SMART_PAUSE_RET2: { + if (this._modelData?.smartPause) + this._parseSmartPause(msg.payload); + break; + } + + case CommandType.AUTO_CALL_RET: + case CommandType.AUTO_CALL_RET2: { + if (this._modelData?.autoAnswer) + this._parseAutoAnswer(msg.payload); + break; + } + + case CommandType.AUTO_POWER_OFF_RET: + case CommandType.AUTO_POWER_OFF_RET2: { + if (this._modelData?.autoPowerOff) + this._parseAutoPowerOff(msg.payload); + break; + } + + case CommandType.CODEC_RET: + case CommandType.CODEC_NOTI: { + if (this._modelData?.reportsCodec) + this._parseCodec(msg.payload); + break; + } + + case CommandType.PAIRED_DEVICEMAX_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevMax(msg.payload); + break; + } + + case CommandType.PAIRED_OWN_DEVICE_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevOwn(msg.payload); + break; + } + + case CommandType.PAIRED_DEVICELIST_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevList(msg.payload); + break; + } + + case CommandType.PAIRED_DEVICEINFO_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevInfo(msg.payload); + break; + } + + case CommandType.PAIRED_DEVICE_STATUS_RET: + case CommandType.PAIRED_DEVICE_STATUS_NOTI: { + if (this._modelData?.dualConnection) + this._parsePairedDevStatus(msg.payload); + break; + } + + case CommandType.PAIRED_DEVICE_CONNECT_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevConnect(); + break; + } + + case CommandType.PAIRED_DEVICE_CONNECT_ERR: { + if (this._modelData?.dualConnection) + this._parsePairedDevConnectErr(); + break; + } + + case CommandType.PAIRED_DEVICE_DISCONNECT_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevDisconnect(); + break; + } + + case CommandType.PAIRED_DEVICE_DISCONNECT_ERR: { + if (this._modelData?.dualConnection) + this._parsePairedDevDisconnectErr(); + break; + } + + case CommandType.PAIRED_DEVICE_REMOVE_RET: { + if (this._modelData?.dualConnection) + this._parsePairedDevRemove(); + break; + } + + case CommandType.PAIRED_DEVICE_REMOVE_ERR: { + if (this._modelData?.dualConnection) + this._parsePairedDevRemoveErr(); + break; + } + + default: + this._log.info(`Unhandled command ${hexBytes(msg.command)}`); + } + } + + _encodeQcomm(command, loginfo, payload = []) { + this._queueRequest(VendorType.QCOM, command, payload, loginfo); + } + + _encodeSenh(command, loginfo, payload = []) { + this._queueRequest(VendorType.SENH, command, payload, loginfo); + } + + _getModelId() { + this._log.info('Get Model ID'); + this._encode(VendorType.SENH, CommandType.MODELID_GET); + } + + _parseModelId(payload) { + if (this._initialized) + return; + + this._initialized = true; + const versionModelId = new TextDecoder().decode(Uint8Array.from(payload)); + const modelId = versionModelId.split(' ')[0]; + this._modelData = SenhBudsModelList.find(model => model.id?.includes(modelId)); + + if (!this._modelData) { + this._log.info(`No model matched for Model ID: ${modelId} ` + + `payload: [${hexBytes(payload)}]`); + return; + } + + this._callbacks?.modelIntialized?.(this._modelData, modelId); + this._sendInitializationRequests(); + } + + _sendInitializationRequests() { + this._registerNotifications(); + this._getConfiguration(); + } + + _registerNotifications() { + const notifications = this._modelData.registerNotification; + + for (const feature of notifications) { + const payload = [feature]; + const loginfo = `Register notification feature: ${hexBytes(feature)}`; + this._encodeSenh(CommandType.REGISTER_NOTI_SET, loginfo, payload); + } + } + + _getConfiguration() { + this._getFirmware(); + this._getSerial(); + this._getBatteryLevel(); + this._getBatteryStatus(); + this._getInEarState(); + + if (this._modelData.noiseControl) + this._getNoiseControl(); + + if (this._modelData.noiseControl) + this._getNoiseControlMode(); + + if (this._modelData.noiseControl?.type === 2) + this._getAncTransparencyLevel(); + + if (this._modelData.audioMode) + this._getAudioMode(); + + if (this._modelData.eq) { + this._getEqAllBands(); + this._getEqConfig(); + } + + if (this._modelData.eq?.bassBoost) + this._getBassBoost(); + + if (this._modelData.peq) + this._getAllBandParams(); + + if (this._modelData.crossfeed) + this._getCrossfeed(); + + if (this._modelData.sideTone) + this._getSideTone(); + + if (this._modelData.comfortCalls) + this._getComfortCall(); + + if (this._modelData.transparencyPause) + this._getTransPause(); + + if (this._modelData.inEarDetection) { + this._getInEarSetting(); + + if (this._modelData.smartPause) + this._getSmartPause(); + + if (this._modelData.autoAnswer) + this._getAutoAnswer(); + + if (this._modelData.autoPowerOff) + this._getAutoPowerOff(); + } + + if (this._modelData.reportsCodec) + this._getCodec(); + + if (this._modelData.dualConnection) { + this._getPairedDevMax(); + this._getPairedDevOwn(); + this._getPairedDevList(); + } + } + + _getFirmware() { + const loginfo = 'Get Firmware'; + this._encodeSenh(CommandType.FIRMWARE_GET, loginfo); + } + + _parseFirmware(payload) { + if (payload.length < 6) + return; + + const fw = `${payload[0]}.${payload[1]}.${payload[2]}`; + this._callbacks?.updateFirmware?.(fw); + } + + _getSerial() { + const loginfo = 'Get Serial'; + this._encodeQcomm(QcomCommandType.SERIAL_GET, loginfo); + } + + _parseSerial(payload) { + if (!payload) + return; + + const serial = new TextDecoder().decode(new Uint8Array(payload)); + this._callbacks?.updateSerial?.(serial); + } + + _getBatteryLevel() { + const loginfo = 'Get Battery Level'; + this._encodeSenh(CommandType.BATT_LEVEL_GET, loginfo); + } + + _parseBatteryLevel(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Battery Level'); + this._battInfo.battery1Level = payload[0]; + if (this._modelData.batteryMultiple && payload.length >= 2) + this._battInfo.battery2Level = payload[1]; + + if (this._modelData.batteryCase && payload.length >= 3) + this._battInfo.battery3Level = payload[2]; + + this._callbacks?.updateBatteryProps?.(this._battInfo); + } + + _getBatteryStatus() { + const loginfo = 'Get Battery Status'; + this._encodeSenh(CommandType.BATT_STATUS_GET, loginfo); + } + + _parseBatteryStatus(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Battery Status'); + const getBatteryState = byte => { + if (byte === 1) + return 'charging'; + + return 'discharging'; + }; + + this._battInfo.battery1Status = getBatteryState(payload[0]); + if (this._modelData.batteryMultiple && payload.length >= 2) + this._battInfo.battery2Status = getBatteryState(payload[1]); + + if (this._modelData.batteryCase && payload.length >= 3) + this._battInfo.battery3Status = getBatteryState(payload[2]); + + this._callbacks?.updateBatteryProps?.(this._battInfo); + } + + _getInEarState() { + const loginfo = 'Get InEarState'; + this._encodeSenh(CommandType.INEAR_STATE_GET, loginfo); + } + + _parseInEarState(payload) { + if (payload.length < 1) + return; + + const stateToString = state => { + switch (state) { + case 0: + return 'UNKNOWN'; + case 1: + return 'IN_CASE'; + case 2: + return 'NOT_ON_HEAD'; + case 3: + return 'ON_HEAD'; + default: + return `INVALID(${state})`; + } + }; + + const bud1 = stateToString(payload[0]); + let bud2 = bud1; + if (this._modelData.type === 'earbuds' && payload.length >= 2) + bud2 = stateToString(payload[1]); + + this._log.info(`Parse Buds1State: ${bud1} Buds2State: ${bud2}`); + this._callbacks?.updateInEarStatus?.(bud1, bud2); + } + + _getNoiseControl() { + const loginfo = 'Get NoiseControl'; + this._encodeSenh(CommandType.ANC_STATUS_GET, loginfo); + } + + _parseNoiseControl(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse NoiseControl'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + if (this._modelData.noiseControl?.type === 2) + this._callbacks?.updateNoiseControl?.(enable); + } + + setNoiseControl(enable) { + const loginfo = 'Set NoiseControl'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.ANC_STATUS_SET, loginfo, payload); + } + + _getNoiseControlMode() { + const loginfo = 'Get NoiseControl Mode'; + this._encodeSenh(CommandType.ANC_MODE_GET, loginfo); + } + + _parseNoiseControlMode(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse NoiseControl Mode'); + const windMode = payload[1]; + const comfortState = payload[3] === 0x01; + const adaptiveState = payload[5] === 0x01; + + if (this._modelData.noiseControl?.type === 2) + this._callbacks?.updateNoiseControlMode?.(windMode, comfortState, adaptiveState); + } + + setNoiseControlMode(mode, value) { + const loginfo = 'Set NoiseControl Mode'; + let state; + if (mode !== 1) + state = value ? 0x01 : 0x00; + else + state = value; + const payload = [mode, state]; + this._encodeSenh(CommandType.ANC_MODE_SET, loginfo, payload); + } + + _getAncTransparencyLevel() { + const loginfo = 'Get AncTransparencyLevel'; + this._encodeSenh(CommandType.ANC_TRANSP_LEVEL_GET, loginfo); + } + + _parseAncTransparencyLevel(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AncTransparencyLevel'); + const level = payload[0]; + + if (this._modelData.noiseControl?.type === 2) + this._callbacks?.updateAncTransparencyLevel?.(level); + } + + setAncTransparencyLevel(level) { + const loginfo = 'Set AncTransparencyLevel'; + const payload = [level]; + this._encodeSenh(CommandType.ANC_TRANSP_LEVEL_SET, loginfo, payload); + } + + _getAudioMode() { + const loginfo = 'Get AudioMode'; + this._encodeSenh(CommandType.AUDIO_MODE_GET, loginfo); + } + + _parseAudioMode(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse AudioMode'); + const mode = payload[1]; + if (!isValidByte(mode, this._modelData.audioMode)) { + this._log.info(`Received invalid audio Mode level: ${mode} Supported?`); + return; + } + this._callbacks?.updateAudioMode?.(mode); + } + + setAudioMode(mode) { + const loginfo = 'Set AudioMode'; + const payload = [0x00, mode]; + this._encodeSenh(CommandType.AUDIO_MODE_SET, loginfo, payload); + } + + _getEqConfig() { + const loginfo = 'Get EqConfig'; + this._encodeSenh(CommandType.EQ_CONFIG_GET, loginfo); + } + + _parseEqConfig(payload) { + if (payload.length < 5) + return; + + const bandCount = payload[0]; + const minGain = payload[1]; + const maxGain = payload[2]; + const selPresets = payload[3]; + const userPresets = payload[4]; + + this._log.info(`Parse EqConfig bandCount: ${bandCount} minGain: ${minGain} ` + + `maxGain: ${maxGain} selPresets: ${selPresets} userPresets: ${userPresets} `); + } + + _getEqAllBands() { + const bands = this._modelData?.eq?.displayedBand; + if (!bands || bands.length === 0) + return; + + for (let index = 0; index < bands.length; index++) { + const loginfo = `Get EqAllBands ${index}`; + const payload = [index]; + this._encodeSenh(CommandType.EQ_BAND_GET, loginfo, payload); + } + } + + _decodeSignedBytes(arr) { + return arr.map(v => (v > 127 ? v - 256 : v) / 10); + } + + _encodeSignedByte(value) { + const scaled = Math.round(value * 10); + return scaled < 0 ? scaled + 256 : scaled; + } + + _parseEqBand(payload) { + const bandCount = this._modelData?.eq?.displayedBand?.length ?? 0; + if (payload.length < bandCount) + return; + + this._log.info('Parse EqBand'); + + const arr = this._decodeSignedBytes(payload); + this._callbacks?.updateEqBand?.(arr); + } + + setEqBand(bandIndex, gain) { + const loginfo = 'Set EqBand'; + const gainByte = this._encodeSignedByte(gain); + const payload = [bandIndex, gainByte]; + this._encodeSenh(CommandType.EQ_BAND_SET, loginfo, payload); + } + + _getBassBoost() { + const loginfo = 'Get BassBoost'; + this._encodeSenh(CommandType.BASS_BOOST_GET, loginfo); + } + + _parseBassBoost(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse BassBoost'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + this._callbacks?.updateBassBoost?.(enable); + } + + setBassBoost(enable) { + const loginfo = 'Set BassBoost'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.BASS_BOOST_SET, loginfo, payload); + } + + _getPeqFreq(stage) { + const loginfo = `Get PEQ Frequency Stage ${stage}`; + this._encodeSenh(CommandType.PEQ_FREQ_GET, loginfo, [stage]); + } + + _parsePeqFreq(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse PEQ Frequency'); + const arr = []; + for (let i = 0; i + 2 < payload.length; i += 3) + arr.push({stage: payload[i], freq: payload[i + 1] << 8 | payload[i + 2]}); + + + this._callbacks?.updatePeqFreq?.(arr); + } + + setPeqFreq(stage, freq) { + const loginfo = `Set PEQ Frequency Stage ${stage}`; + const payload = [stage, freq >> 8 & 0xFF, freq & 0xFF]; + this._encodeSenh(CommandType.PEQ_FREQ_SET, loginfo, payload); + } + + _getPeqGain(stage) { + const loginfo = `Get PEQ Gain Stage ${stage}`; + this._encodeSenh(CommandType.PEQ_GAIN_GET, loginfo, [stage]); + } + + _parsePeqGain(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse PEQ Gain'); + const arr = []; + for (let i = 0; i + 2 < payload.length; i += 3) { + let raw = payload[i + 1] << 8 | payload[i + 2]; + if (raw & 0x8000) + raw -= 0x10000; + + arr.push({stage: payload[i], gain: raw / 10.0}); + } + + this._callbacks?.updatePeqGain?.(arr); + } + + setPeqGain(stage, gain) { + const loginfo = `Set PEQ Gain Stage ${stage}`; + + let raw = Math.round(gain * 10); + + if (raw < 0) + raw += 0x10000; + + const payload = [stage, raw >> 8 & 0xFF, raw & 0xFF]; + + this._encodeSenh(CommandType.PEQ_GAIN_SET, loginfo, payload); + } + + _getPeqQ(stage) { + const loginfo = `Get PEQ Q Stage ${stage}`; + this._encodeSenh(CommandType.PEQ_Q_GET, loginfo, [stage]); + } + + _parsePeqQ(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse PEQ Q'); + const arr = []; + for (let i = 0; i + 2 < payload.length; i += 3) { + const raw = payload[i + 1] << 8 | payload[i + 2]; + arr.push({stage: payload[i], q: raw / 4096.0}); + } + + this._callbacks?.updatePeqQ?.(arr); + } + + setPeqQ(stage, q) { + const loginfo = `Set PEQ Q Stage ${stage}`; + const raw = Math.round(q * 4096); + const payload = [stage, raw >> 8 & 0xFF, raw & 0xFF]; + this._encodeSenh(CommandType.PEQ_Q_SET, loginfo, payload); + } + + _getPeqFilter(stage) { + const loginfo = `Get PEQ Filter Stage ${stage}`; + this._encodeSenh(CommandType.PEQ_FILTER_GET, loginfo, [stage]); + } + + _parsePeqFilter(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse PEQ Filter'); + const arr = []; + for (let i = 0; i + 1 < payload.length; i += 2) + arr.push({stage: payload[i], filter: payload[i + 1]}); + + + this._callbacks?.updatePeqFilter?.(arr); + } + + setPeqFilter(stage, filterType) { + const loginfo = `Set PEQ Filter Stage ${stage}`; + const payload = [stage, filterType]; + this._encodeSenh(CommandType.PEQ_FILTER_SET, loginfo, payload); + } + + _getPeqPreGain() { + const loginfo = 'Get PEQ PreGain'; + this._encodeSenh(CommandType.PEQ_PREGAIN_GET, loginfo); + } + + _parsePeqPreGain(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse PEQ PreGain'); + let raw = payload[0] << 8 | payload[1]; + if (raw & 0x8000) + raw -= 0x10000; + + this._callbacks?.updatePeqPreGain?.(raw / 10.0); + } + + _parsePeqPreGainNoti(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse PEQ PreGain Notification'); + let raw = payload[1] << 8 | payload[0]; + if (raw & 0x8000) + raw -= 0x10000; + + this._callbacks?.updatePeqPreGain?.(raw / 10.0); + } + + setPeqPreGain(gain) { + const loginfo = 'Set PEQ PreGain'; + let raw = Math.round(gain * 10); + if (raw < 0) + raw += 0x10000; + + const payload = [raw >> 8 & 0xFF, raw & 0xFF]; + this._encodeSenh(CommandType.PEQ_PREGAIN_SET, loginfo, payload); + } + + _getAllBandParams() { + for (let stage = 0; stage < this._modelData.peq.maxBands; stage++) { + this._getPeqFreq(stage); + this._getPeqGain(stage); + this._getPeqQ(stage); + this._getPeqFilter(stage); + } + this._getPeqPreGain(); + } + + _getCrossfeed() { + const loginfo = 'Get Crossfeed'; + this._encodeSenh(CommandType.CROSSFEED_GET, loginfo); + } + + _parseCrossfeed(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Crossfeed'); + const level = payload[0]; + if (!isValidByte(level, this._modelData.crossfeed)) { + this._log.info(`Received invalid CrossFeed level: ${level} Supported?`); + return; + } + this._callbacks?.updateCrossfeed?.(level); + } + + setCrossfeed(level) { + const loginfo = 'Set Crossfeed'; + const payload = [level]; + this._encodeSenh(CommandType.CROSSFEED_SET, loginfo, payload); + } + + _getSideTone() { + const loginfo = 'Get SideTone'; + this._encodeSenh(CommandType.SIDETONE_GET, loginfo); + } + + _parseSideTone(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse SideTone'); + const level = payload[0]; + if (level > this._modelData.sideTone - 1) { + this._log.info(`Received invalid side tone level: ${level} Supported?`); + return; + } + this._callbacks?.updateSideTone?.(level); + } + + setSideTone(level) { + const loginfo = 'Set SideTone'; + const payload = [level]; + this._encodeSenh(CommandType.SIDETONE_SET, loginfo, payload); + } + + _getComfortCall() { + const loginfo = 'Get ComfortCall'; + this._encodeSenh(CommandType.COMFORT_CALL_GET, loginfo); + } + + _parseComfortCall(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse ComfortCall'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + this._callbacks?.updateComfortCall?.(enable); + } + + setComfortCall(enable) { + const loginfo = 'Set ComfortCall'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.COMFORT_CALL_SET, loginfo, payload); + } + + _getTransPause() { + const loginfo = 'Get Transperancy Pause'; + this._encodeSenh(CommandType.PAUSE_ON_TRANS_GET, loginfo); + } + + _parseTransPause(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Transperancy Pause'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + this._callbacks?.updateTransPause?.(enable); + } + + setTransPause(enable) { + const loginfo = 'Set Transperancy Pause'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.PAUSE_ON_TRANS_SET, loginfo, payload); + } + + _getInEarSetting() { + const loginfo = 'Get InEarSetting'; + this._encodeSenh(CommandType.INEAR_SETTING_GET, loginfo); + } + + _parseInEarSetting(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse InEarSetting'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + this._callbacks?.updateInEarSetting?.(enable); + } + + setInEarSetting(enable) { + const loginfo = 'Set InEarSetting'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.INEAR_SETTING_SET, loginfo, payload); + } + + _getSmartPause() { + const loginfo = 'Get SmartPause'; + this._encodeSenh(CommandType.SMART_PAUSE_GET, loginfo); + } + + _parseSmartPause(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse SmartPause'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + this._callbacks?.updateSmartPause?.(enable); + } + + setSmartPause(enable) { + const loginfo = 'Set SmartPause'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.SMART_PAUSE_SET, loginfo, payload); + } + + _getAutoAnswer() { + const loginfo = 'Get AutoAnswer'; + this._encodeSenh(CommandType.AUTO_CALL_GET, loginfo); + } + + _parseAutoAnswer(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse AutoAnswer'); + const enable = booleanFromByte(payload[0]); + if (enable === null) + return; + + this._callbacks?.updateAutoAnswer?.(enable); + } + + setAutoAnswer(enable) { + const loginfo = 'Set AutoAnswer'; + const payload = [enable ? 0x01 : 0x00]; + this._encodeSenh(CommandType.AUTO_CALL_SET, loginfo, payload); + } + + _getAutoPowerOff() { + const loginfo = 'Get AutoPowerOff'; + const payload = [0x00]; + this._encodeSenh(CommandType.AUTO_POWER_OFF_GET, loginfo, payload); + } + + _parseAutoPowerOff(payload) { + if (payload.length < 3) + return; + + this._log.info('Parse AutoPowerOff'); + + const seconds = payload[1] << 8 | payload[2]; + const minutes = Math.floor(seconds / 60); + + if (!this._modelData.autoPowerOff.includes(minutes)) { + this._log.info(`Invalid AutoPowerOff value: ${minutes} minutes`); + return; + } + + this._callbacks?.updateAutoPowerOff?.(minutes); + } + + setAutoPowerOff(minutes) { + const loginfo = 'Set AutoPowerOff'; + const seconds = minutes * 60; + const payload = [0x00, seconds >> 8 & 0xFF, seconds & 0xFF]; + this._encodeSenh(CommandType.AUTO_POWER_OFF_SET, loginfo, payload); + } + + _getCodec() { + const loginfo = 'Get Codec'; + this._encodeSenh(CommandType.CODEC_GET, loginfo); + } + + _parseCodec(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse Codec'); + const codec = payload[0]; + this._callbacks?.updateCodec?.(codec); + } + + _getPairedDevMax() { + const loginfo = 'Get PairedDevMax'; + this._encodeSenh(CommandType.PAIRED_DEVICEMAX_GET, loginfo); + } + + _parsePairedDevMax(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse PairedDevMax'); + const size = payload[0]; + this._callbacks?.updatePairedDevMax?.(size); + } + + _getPairedDevOwn() { + const loginfo = 'Get PairedDevOwn'; + this._encodeSenh(CommandType.PAIRED_OWN_DEVICE_GET, loginfo); + } + + _parsePairedDevOwn(payload) { + if (payload.length < 1) + return; + + this._log.info('Parse PairedDevOwn'); + const index = payload[0]; + this._callbacks?.updatePairedDevOwn?.(String(index)); + } + + _getPairedDevList() { + const loginfo = 'Get PairedDevList'; + this._encodeSenh(CommandType.PAIRED_DEVICELIST_GET, loginfo); + } + + _parsePairedDevList(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse PairedDevList'); + const size = payload[0] << 8 | payload[1]; + this._callbacks?.updatePairedDevList?.(size); + + for (let index = 0; index < size; index++) + this._getPairedDevInfo(index); + } + + _getPairedDevInfo(index) { + const loginfo = 'Get PairedDevList'; + const payload = [index]; + this._encodeSenh(CommandType.PAIRED_DEVICELIST_GET, loginfo, payload); + } + + _parsePairedDevInfo(payload) { + if (payload.length < 4) + return; + + this._log.info('Parse PairedDevInfo'); + + const id = String(payload[0]); + const connected = payload[2] !== 0; + + const nameBytes = payload.slice(3); + const nullIndex = nameBytes.indexOf(0); + const nameData = nullIndex >= 0 ? nameBytes.slice(0, nullIndex) : nameBytes; + const deviceName = new TextDecoder().decode(new Uint8Array(nameData)); + this._callbacks?.updatePairedDevInfo?.(id, deviceName, connected); + } + + _getPairedDevStatus(index) { + const loginfo = 'Get PairedDevStatus'; + const payload = [Number(index)]; + this._encodeSenh(CommandType.PAIRED_DEVICE_STATUS_GET, loginfo, payload); + } + + _parsePairedDevStatus(payload) { + if (payload.length < 2) + return; + + this._log.info('Parse PairedDevStatus'); + const index = payload[0]; + const connected = payload[1]; + this._callbacks?.updatePairedDevStatus?.(String(index), connected); + } + + _parsePairedDevConnect() { + this._log.info('Parse PairedDevConnect'); + } + + _parsePairedDevConnectErr() { + this._log.info('Parse PairedDevConnectErr'); + this._callbacks?.updatePairedDevConnectErr?.(); + } + + connectPairedDev(index) { + const loginfo = 'Connect PairedDev'; + const payload = [Number(index)]; + this._encodeSenh(CommandType.PAIRED_DEVICE_CONNECT_SET, loginfo, payload); + } + + _parsePairedDevDisconnect() { + this._log.info('Parse PairedDevDisconnect'); + } + + _parsePairedDevDisconnectErr() { + this._log.info('Parse PairedDevDisconnectErr'); + this._callbacks?.updatePairedDevDisconnectErr?.(); + } + + disconnectPairedDev(index) { + const loginfo = 'Disconnect PairedDev'; + const payload = [Number(index)]; + this._encodeSenh(CommandType.PAIRED_DEVICE_DISCONNECT_SET, loginfo, payload); + } + + _parsePairedDevRemove() { + this._log.info('Parse PairedDevRemove'); + this._getPairedDevList(); + } + + _parsePairedDevRemoveErr() { + this._log.info('Parse PairedDevRemoveErr'); + this._callbacks?.updatePairedDevRemoveErr?.(); + this._getPairedDevList(); + } + + removePairedDev(index) { + const loginfo = 'Remove PairedDev'; + const payload = [Number(index)]; + this._encodeSenh(CommandType.PAIRED_DEVICE_REMOVE_SET, loginfo, payload); + } + + destroy() { + super.destroy?.(); + } +}); diff --git a/bbm/lib/devices/socketByProfile.js b/bbm/lib/devices/socketByProfile.js new file mode 100644 index 0000000000..66e7549581 --- /dev/null +++ b/bbm/lib/devices/socketByProfile.js @@ -0,0 +1,169 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from './logger.js'; + +const LOG_BYTES = true; + +export const SocketHandler = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SocketHandler', +}, class SocketHandler extends GObject.Object { + _init(devicePath, profileManager, profile) { + super._init(); + const subclassName = this.constructor.name; + const identifier = getDeviceIdentifier(devicePath); + const tag = `SocketHandler-${subclassName}-${identifier}`; + this._socketLog = createLogger(tag); + this._devicePath = devicePath; + this._profileManager = profileManager; + this._profile = profile; + this.running = false; + this._cancellable = new Gio.Cancellable(); + this._output_queue = []; + } + + async startSocket() { + const fd = await this._profileManager.acquireFd( + this._profile.type, + this._profile.uuid, + this._devicePath + ); + + if (fd !== -1) + this._attachSocket(fd); + } + + // Credits: GSCConnect + // https://github.com/jtojnar/gnome-shell-extension-gsconnect/ + // blob/bb77316b75f330740ffc3523cd1496b5db0f8199/src/service/bluetooth.js#L321 + + async _attachSocket(fd) { + this._socketLog.info(`Starting socket with fd: ${fd}`); + try { + this._socket = Gio.Socket.new_from_fd(fd); + } catch (e) { + this._socketLog.error(e, 'Error creating socket by fd'); + return; + } + this._connection = this._socket.connection_factory_create_connection(); + this._input_stream = this._connection.get_input_stream(); + this._output_stream = this._connection.get_output_stream(); + + this.running = true; + this._sending = false; + + try { + this._receiveLoop(); + await this.postConnectInitialization(); + } catch (e) { + this._socketLog.error(e, 'Error post connection initialization'); + this.destroy(); + } + } + + async _receiveLoop() { + if (!this.running) + return; + + try { + const bytes = await this._input_stream.read_bytes_async( + 1024, GLib.PRIORITY_DEFAULT, this._cancellable + ); + + if (!bytes || bytes.get_size() === 0) { + this._socketLog.info('Received empty or null data — stopping receive loop'); + this.destroy(); + return; + } + + const array = bytes.toArray(); + + if (LOG_BYTES) { + this._socketLog.bytes('⬅ Received:', Array.from(array).map( + b => b.toString(16).padStart(2, '0')).join(' ')); + } + + this.processData(array); + + this._receiveLoop(); + } catch (e) { + this._socketLog.error(e, 'SocketHandler Disconnected'); + this.destroy(); + } + } + + async sendMessage(packet) { + if (!this.running) + return; + + if (LOG_BYTES) { + this._socketLog.bytes('➡ Sent', Array.from(packet).map( + b => b.toString(16).padStart(2, '0')).join(' ')); + } + + this._output_queue.push(Uint8Array.from(packet)); + if (this._sending) + return; + + this._sending = true; + + while (this._output_queue.length > 0 && this.running) { + const buf = this._output_queue.shift(); + try { + // eslint-disable-next-line no-await-in-loop + await this._output_stream.write_all_async( + buf, GLib.PRIORITY_DEFAULT, this._cancellable, null + ); + } catch (e) { + this._socketLog.error(e, 'Send Message'); + this.destroy(); + this._disconnectProfileCb?.(); + break; + } + } + + this._sending = false; + } + + postConnectInitialization() { + } + + processData() { + } + + destroy() { + if (!this.running) + return; + + this.running = false; + this._cancellable.cancel(); + this._output_queue = []; + this._socketLog.info('Destroying socket'); + + try { + this._socket.shutdown(true, true); + } catch (e) { + this._socketLog.error(e, 'Error shutting down Bluetooth socket'); + } + + try { + this._connection?.close(null); + } catch (e) { + this._socketLog.error(e, 'Error closing Gio.SocketConnection'); + } + + try { + this._socket?.close(); + } catch (e) { + this._socketLog.error(e, 'Error closing Gio.Socket'); + } + this._connection = null; + this._input_stream = null; + this._output_stream = null; + this._socket = null; + this._profileManager.releaseFd(this._profile.type, this._devicePath, true); + } +}); + diff --git a/bbm/lib/devices/sony/deviceConfigs/LinkBuds.js b/bbm/lib/devices/sony/deviceConfigs/LinkBuds.js new file mode 100644 index 0000000000..bea53ced76 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/LinkBuds.js @@ -0,0 +1,28 @@ +'use strict'; + +export default { + name: 'LinkBuds', + pattern: /^LinkBuds$/, + modaliasPrefix: 'v054Cp0DDF', + batteryDual: true, + batteryCase: true, + speakToChatEnabled: true, + speakToChatConfig: true, + adaptiveVolumeControl: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['vol', 'pb', 'na'], // qa, alx, song, vass + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + wideAreaTap: true, + voiceNotifications: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds-wingtip', + budsIcon: 'earbuds-wingtip', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/LinkBudsS.js b/bbm/lib/devices/sony/deviceConfigs/LinkBudsS.js new file mode 100644 index 0000000000..cedb5a98e7 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/LinkBudsS.js @@ -0,0 +1,30 @@ +'use strict'; + +export default { + name: 'LinkBuds S', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*LinkBuds S.*/, + modaliasPrefix: 'v054Cp0DF4', + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + ambientSoundControlButtonMode: true, + quickAccess: true, + audioUpsampling: true, + buttonModesLeftRight: ['amb', 'vol', 'pb', 'na'], // alx, ggle + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + powerOffFromPhone: true, + speakToChatEnabled: true, + speakToChatConfig: true, + voiceNotifications: true, + equalizerSixBands: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/Sony-ULT.js b/bbm/lib/devices/sony/deviceConfigs/Sony-ULT.js new file mode 100644 index 0000000000..4566a3a675 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/Sony-ULT.js @@ -0,0 +1,15 @@ +'use strict'; + +export default { + name: 'Sony ULT', + pattern: /^(Sony ULT|ULT WEAR)$/, + batterySingle: true, + ambientSoundControl: true, + powerOffFromPhone: true, + pauseWhenTakenOff: true, + quickAccess: true, + voiceNotifications: true, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-1000XM3.js b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM3.js new file mode 100644 index 0000000000..57e32432bf --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM3.js @@ -0,0 +1,22 @@ +'use strict'; + +export default { + name: 'WF-1000XM3', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WF-1000XM3.*/, + modaliasPrefix: 'v054Cp0CE0', + batteryDual: true, + batteryCase: true, + powerOffFromPhone: true, + ambientSoundControl: true, + windNoiseReduction: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['amb', 'vol', 'pb', 'na'], + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + voiceNotifications: true, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-normal', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-1000XM4.js b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM4.js new file mode 100644 index 0000000000..ad9f7060a1 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM4.js @@ -0,0 +1,25 @@ +'use strict'; + +export default { + name: 'WF-1000XM4', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WF-1000XM4.*/, + modaliasPrefix: 'v054Cp0DE1', + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + windNoiseReduction: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['amb', 'vol', 'pb', 'na'], + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-1000XM5.js b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM5.js new file mode 100644 index 0000000000..80165c0e88 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM5.js @@ -0,0 +1,27 @@ +'use strict'; + +export default { + name: 'WF-1000XM5', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WF-1000XM5.*/, + modaliasPrefix: 'v054Cp0E63', + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + speakToChatEnabled: true, + speakToChatConfig: true, + equalizerSixBands: true, + audioUpsampling: true, + voiceNotifications: true, + buttonModesLeftRight: ['ambqa', 'pb', 'na'], + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-1000XM6.js b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM6.js new file mode 100644 index 0000000000..0804bc2dd2 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-1000XM6.js @@ -0,0 +1,32 @@ +'use strict'; + +export default { + name: 'WF-1000XM6', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WF-1000XM6.*/, + batteryDual: true, + batteryCase: true, + powerOffFromPhone: true, + ambientSoundControl: true, + ambientSoundControlNA: true, + ambientSoundControlButtonMode: true, + speakToChatEnabled: true, + speakToChatConfig: true, + speakToChatFocusOnVoice: true, + voiceNotifications: true, + voiceNotificationsVolume: true, + automaticPowerOffWhenTakenOff: true, + equalizerTenBands: true, + listeningMode: true, + quickAccess: true, + audioUpsampling: true, + pauseWhenTakenOff: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-C500.js b/bbm/lib/devices/sony/deviceConfigs/WF-C500.js new file mode 100644 index 0000000000..ca13c6e7bc --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-C500.js @@ -0,0 +1,16 @@ +'use strict'; + +export default { + name: 'WF-C500', + pattern: /.*WF-C500.*/, + modaliasPrefix: 'v054Cp0DD4', + batteryDual: true, + equalizerSixBands: true, + audioUpsampling: true, + voiceNotifications: true, + powerOffFromPhone: true, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-C510.js b/bbm/lib/devices/sony/deviceConfigs/WF-C510.js new file mode 100644 index 0000000000..1589161656 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-C510.js @@ -0,0 +1,22 @@ +'use strict'; + +export default { + name: 'WF-C510', + pattern: /.*WF-C510.*/, + modaliasPrefix: 'v054Cp0F36', + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + noNoiseCancelling: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['ambqa', 'pb', 'na'], + voiceNotifications: true, + voiceNotificationsVolume: true, + powerOffFromPhone: true, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval-short', + +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-C700N.js b/bbm/lib/devices/sony/deviceConfigs/WF-C700N.js new file mode 100644 index 0000000000..be6effcefa --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-C700N.js @@ -0,0 +1,26 @@ +'use strict'; + +export default { + name: 'WF-C700N', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WF-C700N.*/, + modaliasPrefix: 'v054Cp0EC4', + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['amb', 'vol', 'pb', 'na'], + powerOffFromPhone: true, + ambientSoundControlButtonMode: true, + automaticPowerOffWhenTakenOff: true, + automaticPowerOffByTime: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-C710N.js b/bbm/lib/devices/sony/deviceConfigs/WF-C710N.js new file mode 100644 index 0000000000..e938c5dce8 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-C710N.js @@ -0,0 +1,18 @@ +'use strict'; + +export default { + name: 'WF-C710N', + pattern: /.*WF-C710N.*/, + batteryDual: true, + batteryCase: true, + ambientSoundControl: true, + equalizerSixBands: true, + audioUpsampling: true, + buttonModesLeftRight: ['ambqa', 'pb', 'na'], + powerOffFromPhone: true, + ambientSoundControlButtonMode: true, + albumArtIcon: 'earbuds', + budsIcon: 'earbuds', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WF-SP800N.js b/bbm/lib/devices/sony/deviceConfigs/WF-SP800N.js new file mode 100644 index 0000000000..c815ad9903 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WF-SP800N.js @@ -0,0 +1,20 @@ +'use strict'; + +export default { + name: 'WF-SP800N', + pattern: /.*WF-SP800N.*/, + batteryDual: true, + batteryCase: true, + powerOffFromPhone: true, + ambientSoundControl: true, + equalizerSixBands: true, + buttonModesLeftRight: ['amb', 'vol', 'pb', 'na'], // alx + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + voiceNotifications: true, + volume: true, + albumArtIcon: 'earbuds-wingtip', + budsIcon: 'earbuds-wingtip', + case: 'case-oval-short', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-1000XM2.js b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM2.js new file mode 100644 index 0000000000..b0289c6217 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM2.js @@ -0,0 +1,19 @@ +'use strict'; + +export default { + name: 'WH-1000XM2', + pattern: /.*WH-1000XM2.*/, + modaliasPrefix: 'v054Cp0C67', + batterySingle: true, + ambientSoundControl: true, + windNoiseReduction: true, + ancOptimizer: true, + audioSettingsOnlyOnSbcCodec: true, + equalizerSixBands: true, + soundPosition: true, + surroundMode: true, + audioUpsampling: true, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-1000XM3.js b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM3.js new file mode 100644 index 0000000000..77d4f46a3a --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM3.js @@ -0,0 +1,22 @@ +'use strict'; + +export default { + name: 'WH-1000XM3', // uuid: 96cc203e-5068-46ad-b32d-e316f5e069ba (v1) + pattern: /.*WH-1000XM3.*/, + modaliasPrefix: 'v054Cp0CD3', + batterySingle: true, + ambientSoundControl: true, + windNoiseReduction: true, + ancOptimizer: true, + audioSettingsOnlyOnSbcCodec: true, + equalizerSixBands: true, + audioUpsampling: true, + touchSensorSingle: true, + automaticPowerOffWhenTakenOff: true, + automaticPowerOffByTime: true, + voiceNotifications: true, + volume: true, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-1000XM4.js b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM4.js new file mode 100644 index 0000000000..e38861e456 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM4.js @@ -0,0 +1,28 @@ +'use strict'; + +export default { + name: 'WH-1000XM4', // uuid: 96cc203e-5068-46ad-b32d-e316f5e069ba (v1) + pattern: /.*WH-1000XM4.*/, + modaliasPrefix: 'v054Cp0D58', + batterySingle: true, + ambientSoundControl: true, + windNoiseReduction: true, + speakToChatEnabled: true, + speakToChatConfig: true, + speakToChatFocusOnVoice: true, + ancOptimizer: true, + equalizerSixBands: true, + audioUpsampling: true, + touchSensorSingle: true, + pauseWhenTakenOff: true, + automaticPowerOffWhenTakenOff: true, + voiceNotifications: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: false, + hasActiveFix: false, + }, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-1000XM5.js b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM5.js new file mode 100644 index 0000000000..6f6c40d855 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM5.js @@ -0,0 +1,28 @@ +'use strict'; + +export default { + name: 'WH-1000XM5', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WH-1000XM5.*/, + modaliasPrefix: 'v054Cp0DF0', + batterySingle: true, + powerOffFromPhone: true, + ambientSoundControl: true, + ambientSoundControlButtonMode: true, + speakToChatEnabled: true, + speakToChatConfig: true, + speakToChatFocusOnVoice: true, + voiceNotifications: true, + automaticPowerOffWhenTakenOff: true, + equalizerSixBands: true, + quickAccess: true, + pauseWhenTakenOff: true, + audioUpsampling: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-1000XM6.js b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM6.js new file mode 100644 index 0000000000..95e9798e1a --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-1000XM6.js @@ -0,0 +1,31 @@ +'use strict'; + +export default { + name: 'WH-1000XM6', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WH-1000XM6.*/, + modaliasPrefix: 'v054Cp0F8A', + batterySingle: true, + powerOffFromPhone: true, + ambientSoundControl: true, + ambientSoundControlNA: true, + ambientSoundControlButtonMode: true, + speakToChatEnabled: true, + speakToChatConfig: true, + speakToChatFocusOnVoice: true, + voiceNotifications: true, + voiceNotificationsVolume: true, + automaticPowerOffWhenTakenOff: true, + equalizerTenBands: true, + listeningMode: true, + quickAccess: true, + audioUpsampling: true, + pauseWhenTakenOff: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: true, + hasActiveFix: true, + }, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-CH720N.js b/bbm/lib/devices/sony/deviceConfigs/WH-CH720N.js new file mode 100644 index 0000000000..fe056be2c1 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-CH720N.js @@ -0,0 +1,24 @@ +'use strict'; + +export default { + name: 'WH-CH720N', // uuid: 956c7b26-d49a-4ba8-b03f-b17d393cb6e2 (v2) + pattern: /.*WH-CH720N.*/, + modaliasPrefix: 'v054Cp0EAF', + batterySingle: true, + powerOffFromPhone: true, + ambientSoundControl: true, + ambientSoundControlButtonMode: true, + voiceNotifications: true, + automaticPowerOffWhenTakenOff: true, + equalizerSixBands: true, + quickAccess: true, + audioUpsampling: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: false, + hasActiveFix: false, + }, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-XB900N.js b/bbm/lib/devices/sony/deviceConfigs/WH-XB900N.js new file mode 100644 index 0000000000..454c358849 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-XB900N.js @@ -0,0 +1,21 @@ +'use strict'; + +export default { + name: 'WH-XB900N', + pattern: /.*WH-XB900N.*/, + modaliasPrefix: 'v054Cp0CDC', + batterySingle: true, + ambientSoundControl: true, + ambientSoundControlNA: false, + windNoiseReduction: false, + audioSettingsOnlyOnSbcCodec: true, + equalizerSixBands: true, + audioUpsampling: true, + touchSensorSingle: true, + automaticPowerOffWhenTakenOff: true, + automaticPowerOffByTime: true, + voiceNotifications: true, + volume: true, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; diff --git a/bbm/lib/devices/sony/deviceConfigs/WH-XB910N.js b/bbm/lib/devices/sony/deviceConfigs/WH-XB910N.js new file mode 100644 index 0000000000..6d04bcc2e6 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WH-XB910N.js @@ -0,0 +1,23 @@ +'use strict'; + +export default { + name: 'WH-XB910N', + pattern: /.*WH-XB910N.*/, + modaliasPrefix: 'v054Cp0DDC', + batterySingle: true, + ambientSoundControl: true, + powerOffFromPhone: true, + pauseWhenTakenOff: true, + quickAccess: true, + equalizerSixBands: true, + voiceNotifications: true, + automaticPowerOffWhenTakenOff: true, + dualConnection: { + hasRoutingIndicator: true, + hasRoutingControl: false, + hasActiveFix: false, + }, + albumArtIcon: 'headphone1', + budsIcon: 'headphone1', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WI-C100.js b/bbm/lib/devices/sony/deviceConfigs/WI-C100.js new file mode 100644 index 0000000000..ea66d0e388 --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WI-C100.js @@ -0,0 +1,14 @@ +'use strict'; + +export default { + name: 'WI-C100', + pattern: /.*WI-C100.*/, + batterySingle: true, + equalizerSixBands: true, + audioUpsampling: true, + voiceNotifications: true, + powerOffFromPhone: true, + albumArtIcon: 'earbuds-neckband', + budsIcon: 'earbuds-neckband', +}; + diff --git a/bbm/lib/devices/sony/deviceConfigs/WI-SP600N.js b/bbm/lib/devices/sony/deviceConfigs/WI-SP600N.js new file mode 100644 index 0000000000..7c8a750b0d --- /dev/null +++ b/bbm/lib/devices/sony/deviceConfigs/WI-SP600N.js @@ -0,0 +1,16 @@ +'use strict'; + +export default { + name: 'WI-SP600N', + pattern: /.*WI-SP600N.*/, + modaliasPrefix: 'v054Cp0C7E', + batterySingle: true, + ambientSoundControl: true, + equalizerSixBands: true, + voiceNotifications: true, + powerOffFromPhone: true, + volume: true, + albumArtIcon: 'earbuds-neckband', + budsIcon: 'earbuds-neckband', +}; + diff --git a/bbm/lib/devices/sony/sonyConfig.js b/bbm/lib/devices/sony/sonyConfig.js new file mode 100644 index 0000000000..4a5ea4dde1 --- /dev/null +++ b/bbm/lib/devices/sony/sonyConfig.js @@ -0,0 +1,554 @@ +'use strict'; + +import LinkBuds from './deviceConfigs/LinkBuds.js'; +import LinkBudsS from './deviceConfigs/LinkBudsS.js'; +import SonyULT from './deviceConfigs/Sony-ULT.js'; +import WF1000XM3 from './deviceConfigs/WF-1000XM3.js'; +import WF1000XM4 from './deviceConfigs/WF-1000XM4.js'; +import WF1000XM5 from './deviceConfigs/WF-1000XM5.js'; +import WF1000XM6 from './deviceConfigs/WF-1000XM6.js'; +import WFC500 from './deviceConfigs/WF-C500.js'; +import WFC510 from './deviceConfigs/WF-C510.js'; +import WFC700N from './deviceConfigs/WF-C700N.js'; +import WFC710N from './deviceConfigs/WF-C710N.js'; +import WFSP800N from './deviceConfigs/WF-SP800N.js'; +import WH1000XM2 from './deviceConfigs/WH-1000XM2.js'; +import WH1000XM3 from './deviceConfigs/WH-1000XM3.js'; +import WH1000XM4 from './deviceConfigs/WH-1000XM4.js'; +import WH1000XM5 from './deviceConfigs/WH-1000XM5.js'; +import WH1000XM6 from './deviceConfigs/WH-1000XM6.js'; +import WHCH720N from './deviceConfigs/WH-CH720N.js'; +import WHXB900N from './deviceConfigs/WH-XB900N.js'; +import WHXB910N from './deviceConfigs/WH-XB910N.js'; +import WIC100 from './deviceConfigs/WI-C100.js'; +import WISP600N from './deviceConfigs/WI-SP600N.js'; + +/* eslint-disable max-len */ + +export const SonyConfiguration = [ + LinkBuds, + LinkBudsS, + SonyULT, + WF1000XM3, + WF1000XM4, + WF1000XM5, + WF1000XM6, + WFC500, + WFC510, + WFC700N, + WFC710N, + WFSP800N, + WH1000XM2, + WH1000XM3, + WH1000XM4, + WH1000XM5, + WH1000XM6, + WHCH720N, + WHXB900N, + WHXB910N, + WIC100, + WISP600N, +]; + +export const Checksum = { + HEADER: 0x3E, + TRAILER: 0x3C, + ESCAPE: 0x3D, + ESCAPE_MASK: 0xEF, +}; + +export const MessageType = { + ACK: 0x01, + COMMAND_1: 0x0C, + COMMAND_2: 0x0E, + UNKNOWN: 0xFF, +}; + +export const ValueType = { + FIXED: 0x00, + MODEL_NAME: 0x01, + FW_VERSION: 0x02, + SERIES_AND_COLOR_INFO: 0x03, +}; + +export const BatteryType = { + SINGLE: 0x00, + DUAL: 0x01, + CASE: 0x02, + SINGLE_THD: 0x08, + DUAL_THD: 0x09, + CASE_THD: 0x0A, +}; + +export const BatteryStatus = { + NOT_CHARGING: 0x00, + CHARGING: 0x01, + UNKNOWN: 0x02, + CHARGED: 0x03, +}; + +export const AsmType = { + NC_ON_OFF: 0x1, + NC_ON_OFF_AND_ASM_ON_OFF: 0x11, + NC_MODE_SWITCH_AND_ASM_ON_OFF: 0x12, + NC_ON_OFF_AND_ASM_SEAMLESS: 0x13, + NC_MODE_SWITCH_AND_ASM_SEAMLESS: 0x14, + MODE_NC_ASM_AUTO_NC_MODE_SWITCH_AND_ASM_SEAMLESS: 0x15, + MODE_NC_ASM_DUAL_SINGLE_NC_MODE_SWITCH_AND_ASM_SEAMLESS: 0x16, + MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS: 0x17, + MODE_NC_NCSS_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS: 0x18, + MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS_NA: 0x19, + ASM_ON_OFF: 0x21, + ASM_SEAMLESS: 0x22, + NC_AMB_TOGGLE: 0x30, + NC_TEST_MODE: 0x40, +}; + +export const ConnectionType = { + PAIRING_DEVICE_MANAGEMENT_CLASSIC_BT: 0x00, + PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE: 0x02, +}; + +export const AmbientSoundMode = { + ANC_OFF: 0x00, + ANC_ON: 0x01, + WIND: 0x02, + AMBIENT: 0x03, +}; + +export const AutoAsmSensitivity = { + STANDARD: 0x00, + HIGH: 0x01, + LOW: 0x02, +}; + +export const Speak2ChatSensitivity = { + AUTO: 0x00, + HIGH: 0x01, + LOW: 0x02, +}; + +export const Speak2ChatTimeout = { + SHORT: 0x00, + STANDARD: 0x01, + LONG: 0x02, + OFF: 0x03, +}; + +export const ListeningMode = { + CINEMA: 0x00, + STANDARD: 0x01, + BGM: 0x02, // Should not be sent +}; + +export const BgmDistance = { + MY_ROOM: 0x00, + LIVING_ROOM: 0x01, + CAFE: 0x02, +}; + +export const EqInquiryType = { + PRESET_EQ: 0x00, + EBB: 0x01, + PRESET_EQ_NONCUSTOMIZABLE: 0x02, + PRESET_EQ_AND_ERRORCODE: 0x04, + CUSTOM_EQ: 0x31, +}; + +export const EqualizerPreset = { + OFF: 0x00, + BRIGHT: 0x10, + EXCITED: 0x11, + MELLOW: 0x12, + RELAXED: 0x13, + VOCAL: 0x14, + TREBLE_BOOST: 0x15, + BASS_BOOST: 0x16, + SPEECH: 0x17, + MANUAL: 0xA0, + CUSTOM_1: 0xA1, + CUSTOM_2: 0xA2, +}; + +export const ButtonModes = { + AMBIENT_SOUND_CONTROL: 0x00, + VOLUME_CONTROL: 0x10, + PLAYBACK_CONTROL: 0x20, + PLAYBACK_CONTROL_W_LIMITATION: 0x22, + AMBIENT_SOUND_CONTROL_QA: 0x35, + NO_FUNCTION: 0xFF, +}; + +export const AmbientButtonMode = { + NC_ASM_OFF: 0x01, + NC_ASM: 0x02, + NC_OFF: 0x03, + ASM_OFF: 0x04, +}; + +export const AutoPowerOffState = { + ENABLE: 0x10, + DISABLE: 0x11, +}; + +export const AutoPowerOffTime = { + AFTER_5_MIN: 0x00, + AFTER_30_MIN: 0x01, + AFTER_1_HOUR: 0x02, + AFTER_3_HOUR: 0x03, +}; + +export const AudioCodec = { + UNSETTLED: 0x00, + SBC: 0x01, + AAC: 0x02, + LDAC: 0x10, + APT_X: 0x20, + APT_X_HD: 0x21, + LC3: 0x30, + OTHER: 0xFF, +}; + +export const DseeType = { + DSEE_HX: 0x00, + DSEE: 0x01, + DSEE_HX_AI: 0x02, + DSEE_ULTIMATE: 0x03, +}; + +export const PayloadTypeV1T1 = { + CONNECT_GET_PROTOCOL_INFO: 0x00, + CONNECT_RET_PROTOCOL_INFO: 0x01, + CONNECT_GET_CAPABILITY_INFO: 0x02, + CONNECT_GET_DEVICE_INFO: 0x04, + CONNECT_RET_DEVICE_INFO: 0x05, + CONNECT_GET_SUPPORT_FUNCTION: 0x06, + CONNECT_RET_SUPPORT_FUNCTION: 0x07, + COMMON_GET_BATTERY_LEVEL: 0x10, + COMMON_RET_BATTERY_LEVEL: 0x11, + COMMON_NTFY_BATTERY_LEVEL: 0x13, + COMMON_GET_UPSCALING_EFFECT: 0x14, + COMMON_RET_UPSCALING_EFFECT: 0x15, + COMMON_NTFY_UPSCALING_EFFECT: 0x17, + COMMON_GET_AUDIO_CODEC: 0x18, + COMMON_RET_AUDIO_CODEC: 0x19, + COMMON_NTFY_AUDIO_CODEC: 0x1B, + VPT_GET_PARAM: 0x46, + VPT_RET_PARAM: 0x47, + VPT_SET_PARAM: 0x48, + VPT_NTFY_PARAM: 0x49, + EQ_EBB_GET_PARAM: 0x56, + EQ_EBB_RET_PARAM: 0x57, + EQ_EBB_SET_PARAM: 0x58, + EQ_EBB_NTFY_PARAM: 0x59, + NC_ASM_GET_PARAM: 0x66, + NC_ASM_RET_PARAM: 0x67, + NC_ASM_SET_PARAM: 0x68, + NC_ASM_NTFY_PARAM: 0x69, + AUDIO_GET_PARAM: 0xE6, + AUDIO_RET_PARAM: 0xE7, + AUDIO_SET_PARAM: 0xE8, + AUDIO_NTFY_PARAM: 0xE9, + SYSTEM_GET_PARAM: 0xF6, + SYSTEM_RET_PARAM: 0xF7, + SYSTEM_SET_PARAM: 0xF8, + SYSTEM_NTFY_PARAM: 0xF9, + SYSTEM_GET_EXTENDED_PARAM: 0xFA, + SYSTEM_RET_EXTENDED_PARAM: 0xFB, + SYSTEM_SET_EXTENDED_PARAM: 0xFC, + SYSTEM_NTFY_EXTENDED_PARAM: 0xFD, +}; + +export const PayloadTypeV1T2 = { + PERI_GET_STATUS: 0x32, + PERI_RET_STATUS: 0x33, + PERI_SET_STATUS: 0x34, + PERI_NTFY_STATUS: 0x35, + PERI_GET_PARAM: 0x36, + PERI_RET_PARAM: 0x37, + PERI_NTFY_PARAM: 0x39, + PERI_SET_EX_PARAM: 0x3C, + PERI_NTFY_EX_PARAM: 0x3D, +}; + +export const FunctionTypeV1T1 = { + NO_USE: 0x00, + BATTERY_LEVEL: 0x11, + UPSCALING_INDICATOR: 0x12, + CODEC_INDICATOR: 0x13, + BLE_SETUP: 0x14, + LEFT_RIGHT_BATTERY_LEVEL: 0x15, + LEFT_RIGHT_CONNECTION_STATUS: 0x17, + CRADLE_BATTERY_LEVEL: 0x18, + POWER_OFF: 0x21, + CONCIERGE_DATA: 0x22, + TANDEM_KEEP_ALIVE: 0x23, + FW_UPDATE: 0x30, + PAIRING_DEVICE_MANAGEMENT_CLASSIC_BT: 0x38, + VOICE_GUIDANCE: 0x39, + VPT: 0x41, + SOUND_POSITION: 0x42, + PRESET_EQ: 0x51, + EBB: 0x52, + PRESET_EQ_NONCUSTOMIZABLE: 0x53, + NOISE_CANCELLING: 0x61, + NOISE_CANCELLING_AND_AMBIENT_SOUND_MODE: 0x62, + AMBIENT_SOUND_MODE: 0x63, + AUTO_NC_ASM: 0x71, + NC_OPTIMIZER: 0x81, + VIBRATOR_ALERT_NOTIFICATION: 0x92, + PLAYBACK_CONTROLLER: 0xA1, + TRAINING_MODE: 0xB1, + ACTION_LOG_NOTIFIER: 0xC1, + GENERAL_SETTING1: 0xD1, + GENERAL_SETTING2: 0xD2, + GENERAL_SETTING3: 0xD3, + CONNECTION_MODE: 0xE1, + UPSCALING: 0xE2, + VIBRATOR: 0xF1, + POWER_SAVING_MODE: 0xF2, + CONTROL_BY_WEARING: 0xF3, + AUTO_POWER_OFF: 0xF4, + SMART_TALKING_MODE: 0xF5, + ASSIGNABLE_SETTINGS: 0xF6, +}; + +export const PayloadTypeV2T1 = { + CONNECT_GET_PROTOCOL_INFO: 0x00, + CONNECT_RET_PROTOCOL_INFO: 0x01, + CONNECT_GET_CAPABILITY_INFO: 0x02, + CONNECT_RET_CAPABILITY_INFO: 0x03, + CONNECT_GET_DEVICE_INFO: 0x04, + CONNECT_RET_DEVICE_INFO: 0x05, + CONNECT_GET_SUPPORT_FUNCTION: 0x06, + CONNECT_RET_SUPPORT_FUNCTION: 0x07, + COMMON_GET_STATUS: 0x12, + COMMON_RET_STATUS: 0x13, + COMMON_NTFY_STATUS: 0x15, + POWER_GET_STATUS: 0x22, + POWER_RET_STATUS: 0x23, + POWER_NTFY_STATUS: 0x25, + POWER_GET_PARAM: 0x26, + POWER_RET_PARAM: 0x27, + POWER_SET_PARAM: 0x28, + POWER_NTFY_PARAM: 0x29, + EQEBB_GET_PARAM: 0x56, + EQEBB_RET_PARAM: 0x57, + EQEBB_SET_PARAM: 0x58, + EQEBB_NTFY_PARAM: 0x59, + NCASM_GET_PARAM: 0x66, + NCASM_RET_PARAM: 0x67, + NCASM_SET_PARAM: 0x68, + NCASM_NTFY_PARAM: 0x69, + AUDIO_GET_PARAM: 0xE6, + AUDIO_RET_PARAM: 0xE7, + AUDIO_SET_PARAM: 0xE8, + AUDIO_NTFY_PARAM: 0xE9, + SYSTEM_GET_PARAM: 0xF6, + SYSTEM_RET_PARAM: 0xF7, + SYSTEM_SET_PARAM: 0xF8, + SYSTEM_NTFY_PARAM: 0xF9, + SYSTEM_GET_EXT_PARAM: 0xFA, + SYSTEM_RET_EXT_PARAM: 0xFB, + SYSTEM_SET_EXT_PARAM: 0xFC, + SYSTEM_NTFY_EXT_PARAM: 0xFD, +}; + +export const PayloadTypeV2T2 = { + CONNECT_GET_SUPPORT_FUNCTION: 0x06, + CONNECT_RET_SUPPORT_FUNCTION: 0x07, + PERI_GET_STATUS: 0x32, + PERI_RET_STATUS: 0x33, + PERI_SET_STATUS: 0x34, + PERI_NTFY_STATUS: 0x35, + PERI_GET_PARAM: 0x36, + PERI_RET_PARAM: 0x37, + PERI_SET_PARAM: 0x38, + PERI_NTFY_PARAM: 0x39, + PERI_SET_EXTENDED_PARAM: 0x3C, + PERI_NTFY_EXTENDED_PARAM: 0x3D, + VOICE_GUIDANCE_GET_PARAM: 0x46, + VOICE_GUIDANCE_RET_PARAM: 0x47, + VOICE_GUIDANCE_SET_PARAM: 0x48, + VOICE_GUIDANCE_NTFY_PARAM: 0x49, +}; + +export const FunctionTypeV2T1 = { + CONCIERGE_DATA: 0x10, + CONNECTION_STATUS: 0x11, + CODEC_INDICATOR: 0x12, + UPSCALING_INDICATOR: 0x13, + BLE_SETUP: 0x14, + TUTORIAL_CONTENTS_SELECT_ON_CONCIERGE: 0x15, + CONNECTION_ESTABLISHED_TIME: 0x16, + UNNECESSARY_AUTO_RECONNECTION: 0x17, + DEVICE_SPECIAL_MODE: 0x18, + PHONE_AND_CONNECTED_DEVICE_INFOMATION_FOR_CLASSIC: 0x19, + TANDEM_RECONNECTION_REQUEST: 0x1A, + DISPLAY_FW_VERSION: 0x1B, + BATTERY_LEVEL_INDICATOR: 0x20, + LEFT_RIGHT_BATTERY_LEVEL_INDICATOR: 0x21, + CRADLE_BATTERY_LEVEL_INDICATOR: 0x22, + POWER_OFF: 0x23, + AUTO_POWER_OFF: 0x24, + AUTO_POWER_OFF_WITH_WEARING_DETECTION: 0x25, + POWER_SAVING_MODE_ON_OFF: 0x26, + TANDEM_KEEP_ALIVE: 0x27, + BATTERY_LEVEL_WITH_THRESHOLD: 0x28, + LR_BATTERY_LEVEL_WITH_THRESHOLD: 0x29, + CRADLE_BATTERY_LEVEL_WITH_THRESHOLD: 0x2A, + BATTERY_SAFE_MODE: 0x2B, + CARING_CHARGE: 0x2C, + BT_STANDBY: 0x2D, + STAMINA: 0x2E, + AUTOMATIC_TOUCH_PANEL_BACKLIGHT_TURN_OFF: 0x2F, + FW_UPDATE_MTK_TRANSFER_WITHOUT_DISCONNECTION: 0x32, + FW_UPDATE_MTK_TRANSFER_WITHOUT_DISCONNECTION_AUTO_UPDATE: 0x34, + FW_UPDATE_MTK_TRANSFER_WITH_REPAIR_MODE: 0x35, + FW_UPDATE_MTK_TRANSFER_WITH_AC_CONNECTION_CHECK: 0x36, + FW_UPDATE_TANDEM_TRANSFER_USING_COMMON_TABLE: 0x37, + FW_UPDATE_USING_MC_APP: 0x38, + TWS_SUPPORTS_A2DP_LEA_UNI_LEA_BROAD_WITH_CTKD: 0x40, + HBS_SUPPORTS_A2DP_LEA_UNI_LEA_BROAD_WITH_CTKD: 0x41, + CLASSIC_ONLY_LE_CLASSIC_SETTING: 0x42, + TWS_SUPPORTS_LEA_UNI_LEA_BROAD: 0x43, + CHANGE_TANDEM_CONNECTION_PROFILE_FOR_ANDROID: 0x44, + BGM_MODE_CANT_BE_USED_WITH_LEA_CONNECTION: 0x45, + HEAD_TRACKER_CANT_BE_USED_WITH_LEA_CONNECTION: 0x46, + PAIRING_DEVICE_MANAGEMENT_CANT_BE_USED_WITH_LEA_CONNECTION: 0x47, + SOUND_AR_CANT_BE_USED_WITH_LEA_CONNECTION: 0x48, + AUTO_PLAY_CANT_BE_USED_WITH_LEA_CONNECTION: 0x49, + GATT_CONNECTABLE_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4A, + SOUND_AR_OPTIMIZATION_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4B, + QUICK_ACCESS_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4C, + CONNECTION_MODE_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4D, + VOICE_ASSISTANT_SETTINGS_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4E, + VOICE_ASSISTANT_WAKE_WORD_CANT_BE_USED_WITH_LEA_CONNECTION: 0x4F, + PRESET_EQ: 0x50, + EBB: 0x51, + PRESET_EQ_NON_CUSTOMIZABLE: 0x52, + PRESET_EQ_AND_ULT_MODE: 0x53, + SOUND_EFFECT: 0x54, + CUSTOM_EQ: 0x55, + TURN_KEY_EQ: 0x56, + PRESET_EQ_AND_ERRORCODE: 0x57, + NOISE_CANCELLING_ONOFF: 0x61, + NOISE_CANCELLING_ONOFF_AND_AMBIENT_SOUND_MODE_ONOFF: 0x62, + NOISE_CANCELLING_DUAL_SINGLE_OFF_AND_AMBIENT_SOUND_MODE_ONOFF: 0x63, + NOISE_CANCELLING_ONOFF_AND_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x64, + NOISE_CANCELLING_DUAL_SINGLE_OFF_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x65, + AMBIENT_SOUND_MODE_ONOFF: 0x66, + AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x67, + MODE_NC_ASM_NOISE_CANCELLING_DUAL_AUTO_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x68, + AMBIENT_SOUND_CONTROL_MODE_SELECT: 0x69, + MODE_NC_ASM_NOISE_CANCELLING_DUAL_SINGLE_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x6A, + MODE_NC_ASM_NOISE_CANCELLING_DUAL_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT: 0x6B, + MODE_NC_NCSS_ASM_NOISE_CANCELLING_DUAL_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT_WITH_TEST_MODE: 0x6C, + MODE_NC_ASM_NOISE_CANCELLING_DUAL_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT_NOISE_ADAPTATION: 0x6D, + AUTO_NCASM: 0x70, + ADAPTIVE_CONTROL_WITH_PARAMETER_NOTIFICATION: 0x71, + NC_OPTIMIZER_PERSONAL_BAROMETRIC: 0x80, + NC_OPTIMIZER_PERSONAL: 0x81, + NC_OPTIMIZER_BAROMETRIC: 0x82, + SOUND_FIELD_OPTIMIZATION: 0x83, + TV_SOUND_BOOSTER: 0x84, + FIXED_MESSAGE: 0x90, + VIBRATOR_ALERT_NOTIFICATION: 0x91, + FIXED_MESSAGE_WITH_LR_SELECTION: 0x92, + VOICE_ASSISTANT_ALERT_NOTIFICATION: 0x93, + LE_AUDIO_ALERT_NOTIFICATION: 0x94, + PLAYBACK_CONTROLLER_WITH_CALL_VOLUME_ADJUSTMENT: 0xA1, + PLAYBACK_CONTROLLER_WITH_CALL_VOLUME_ADJUSTMENT_AND_MUTE: 0xA2, + PLAYBACK_CONTROLLER_WITH_CALL_VOLUME_ADJUSTMENT_AND_FUNCTION_CHANGE: 0xA3, + PLAYBACK_CONTROLLER_WITH_FUNCTION_CHANGE: 0xA4, + SAR: 0xB0, + AUTO_PLAY: 0xB1, + GATT_CONNECTABLE: 0xB2, + SAR_OPTIMIZATION_COMPASS_ACCEL_TYPE: 0xB3, + HEAD_TRACKER_COMPASS_ACCEL_TYPE: 0xB5, + SAR_OPTIMIZATION_ACCEL_TYPE: 0xB6, + HEAD_TRACKER_ACCEL_TYPE: 0xB7, + INTEGRATED_AUTO_PLAY: 0xB8, + ACTION_LOG_NOTIFIER: 0xC1, + TIME_SERIES_OPERATIONLOG_NOTIFIER: 0xC2, + SOUND_DROPOUT_NOTIFIER: 0xC3, + GENERAL_SETTING_1: 0xD1, + GENERAL_SETTING_2: 0xD2, + GENERAL_SETTING_3: 0xD3, + GENERAL_SETTING_4: 0xD4, + CONNECTION_MODE_SOUND_QUALITY_CONNECTION_QUALITY: 0xE1, + UPSCALING_AUTO_OFF: 0xE2, + CONNECTION_MODE_SOUND_QUALITY_SOUND_WITH_LDAC_STATUS_QUALITY_CONNECTION_QUALITY: 0xE3, + BGM_MODE_SMALL_MIDDLE_LARGE: 0xE4, + UPMIX_CINEMA: 0xE5, + LISTENING_OPTION: 0xE6, + CONNECTION_MODE_CLASSIC_AUDIO_LE_AUDIO: 0xE7, + VOICE_CONTENTS: 0xE8, + SOUND_LEAKAGE_REDUCTION: 0xE9, + LISTENING_OPTION_ASSIGN_CUSTOMIZABLE: 0xEA, + BGM_MODE_SMALL_MIDDLE_LARGE_AND_ERRORCODE: 0xEB, + UPMIX_SERIES: 0xEC, + VIBRATOR_ON_OFF: 0xF0, + PLAYBACK_CONTROL_BY_WEARING_REMOVING_HEADPHONE_ON_OFF: 0xF1, + SMART_TALKING_MODE_TYPE1: 0xF2, + ASSIGNABLE_SETTING: 0xF3, + VOICE_ASSISTANT_SETTINGS: 0xF4, + VOICE_ASSISTANT_WAKE_WORD_ON_OFF: 0xF5, + WEARING_STATUS_DETECTOR: 0xF6, + EARPIECE_SELECTION: 0xF7, + CALL_SETTINGS: 0xF8, + RESET_SETTINGS: 0xF9, + AUTO_VOLUME: 0xFA, + FACE_TAP_TEST_MODE: 0xFB, + SMART_TALKING_MODE_TYPE2: 0xFC, + QUICK_ACCESS: 0xFD, + ASSIGNABLE_SETTING_WITH_LIMITATION: 0xFE, + HEAD_GESTURE_ON_OFF_TRAINING: 0xFF, +}; + +export const FunctionTypeV2T2 = { + AUTO_STANDBY: 0x20, + CHARGE_IN_USE: 0x21, + CARING_CHARGE_WITH_THRESHOLD: 0x22, + USB_SUBMERSION: 0x23, + PAIRING_DEVICE_MANAGEMENT_CLASSIC_BT: 0x30, + SOURCE_SWITCH_CONTROL: 0x31, + PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE_CLASSIC_BT: 0x32, + PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE_CLASSIC_LE: 0x33, + MUSIC_HAND_OVER_SETTING: 0x34, + VOICE_GUIDANCE_SETTING_MTK_TRANSFER_WITHOUT_DISCONNECTION_NOT_SUPPORT_LANGUAGE_SWITCH: 0x40, + VOICE_GUIDANCE_SETTING_MTK_TRANSFER_WITHOUT_DISCONNECTION_SUPPORT_LANGUAGE_SWITCH: 0x41, + VOICE_GUIDANCE_SETTING_MTK_TRANSFER_WITHOUT_DISCONNECTION_SUPPORT_LANGUAGE_SWITCH_AND_VOLUME_ADJUSTMENT: 0x42, + VOICE_GUIDANCE_VOLUME_SETTING_MTK_FIXED_TO_5_STEPS: 0x43, + VOICE_GUIDANCE_SETTING_SUPPORT_LANGUAGE_SWITCH: 0x44, + VOICE_GUIDANCE_SETTING_ONLY_ON_OFF_SWITCH: 0x45, + VOICE_GUIDANCE_BATTERY_LEVEL_VOICE: 0x46, + VOICE_GUIDANCE_POWER_ON_OFF_SOUND: 0x47, + VOICE_GUIDANCE_SOUND_EFFECT_ULT_BEEP_ON_OFF: 0x48, + SAFE_LISTENING_HBS_1: 0x50, + SAFE_LISTENING_TWS_1: 0x51, + SAFE_LISTENING_HBS_2: 0x52, + SAFE_LISTENING_TWS_2: 0x53, + SAFE_VOLUME_CONTROL: 0x54, + LE_AUDIO_CONNECTION_STATE_NOTIFICATION: 0x60, + LE_AUDIO_SWITCH_SUPPORTED_COMPATIBILITY: 0x61, + LE_AUDIO_CONNECTION_MODE: 0x62, + GET_IDENTITY_RESOLVING_KEY: 0x63, + LINK_AUTO_SWITCH_CANT_BE_USED_WITH_LEA_CONNECTION: 0x6F, + DJ_CONTROL: 0x70, + ILLUMINATION: 0x71, + KARAOKE: 0x72, + WEARING_STATUS_CHECKER: 0xF0, + REPEAT_TAP_TRAINING_MODE: 0xF1, + QUICK_ACCESS_EASY_SETTING: 0xF2, + AUTO_VOLUME_OPTIMIZER: 0xF3, + AUTO_VOLUME_WITH_LIMITATION: 0xF4, + SONY_VOICE_ASSISTANT: 0xF5, + WEARING_POSITION: 0xF6, + LINK_AUTO_SWITCH_FOR_SPEAKER: 0xF7, + LINK_AUTO_SWITCH_FOR_HEADSETS: 0xF8, + MIC_ON_OFF_BY_HEADPHONE_OPERATION: 0xF9, + FUNCTION_CHANGE: 0xFA, + USB_BROWSER: 0xFB, + LIGHTING_MODE: 0xFC, +}; diff --git a/bbm/lib/devices/sony/sonyDevice.js b/bbm/lib/devices/sony/sonyDevice.js new file mode 100644 index 0000000000..ddc9a80e52 --- /dev/null +++ b/bbm/lib/devices/sony/sonyDevice.js @@ -0,0 +1,1411 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +const _ = s => s; // gettext stub for daemon context + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {getBluezDeviceProxy} from '../../bluezDeviceProxy.js'; +import { + buds2to1BatteryLevel, validateProperties, launchConfigureWindow, isArrayEqual +} from '../deviceUtils.js'; +import {createConfig, createProperties, DataHandler} from '../../dataHandler.js'; +import {SonySocketV1} from './sonySocketV1.js'; +import {SonySocketV2} from './sonySocketV2.js'; +import { + SonyConfiguration, AmbientSoundMode, AutoAsmSensitivity, ListeningMode, + AudioCodec, DseeType +} from './sonyConfig.js'; +import {BtDeviceState, DeviceManagementAction} from '../commonEmuns.js'; + +const SonyUUIDv1 = '96cc203e-5068-46ad-b32d-e316f5e069ba'; +const SonyUUIDv2 = '956c7b26-d49a-4ba8-b03f-b17d393cb6e2'; +export const DeviceTypeSonyV1 = 'sonyV1'; +export const DeviceTypeSonyV2 = 'sonyV2'; + +function isSony(bluezDeviceProxy, uuids, uuid) { + const bluezProps = ['Modalias']; + let supported = 'no'; + + if (!uuids.includes(uuid)) + return {supported, bluezProps}; + + const modalias = bluezDeviceProxy.Modalias; + if (!modalias) { + supported = 'pending'; + return {supported, bluezProps}; + } + + const name = bluezDeviceProxy.Name; + + const isCompatible = SonyConfiguration.some(model => + model.modaliasPrefix && modalias.includes(model.modaliasPrefix) || + model.pattern && model.pattern.test(name) + ); + if (isCompatible) + supported = 'yes'; + + return {supported, bluezProps}; +} + +export function isSonyV1(bluezDeviceProxy, uuids) { + return isSony(bluezDeviceProxy, uuids, SonyUUIDv1); +} + +export function isSonyV2(bluezDeviceProxy, uuids) { + return isSony(bluezDeviceProxy, uuids, SonyUUIDv2); +} + +export const SonyDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SonyDevice', +}, class SonyDevice extends GObject.Object { + _init(settings, devicePath, alias, extPath, profileManager, updateDeviceMapCb) { + super._init(); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SonyDevice-${identifier}`; + this._log = createLogger(tag); + this._log.info('------------------- SonyDevice init -------------------'); + this._settings = settings; + this._devicePath = devicePath; + this._alias = alias; + this._extPath = extPath; + this._profileManager = profileManager; + this.updateDeviceMapCb = updateDeviceMapCb; + this._usesProtocolV2 = false; + this._ignoreGsettingsChange = false; + this._deviceInfo = []; + this._pendingBTOperations = new Map(); + + this._initialize(profileManager); + } + + _initialize(profileManager) { + this._bluezDeviceProxy = getBluezDeviceProxy(this._devicePath); + const uuids = this._bluezDeviceProxy.UUIDs; + const name = this._bluezDeviceProxy.Name; + const modalias = this._bluezDeviceProxy.Modalias; + + if (uuids.includes(SonyUUIDv2)) + this._usesProtocolV2 = true; + + const modelData = SonyConfiguration.find(model => + model.modaliasPrefix && modalias.includes(model.modaliasPrefix) || + model.pattern && model.pattern.test(name) + ); + + if (!modelData) + return; + + this._log.info(`Configuration: ${JSON.stringify(modelData, null, 2)}`); + + this._config = createConfig(); + this._props = createProperties(); + this._ambientMode = AmbientSoundMode.ANC_OFF; + this._focusOnVoiceState = false; + this._ambientLevel = 10; + this._naMode = true; + this._naSensitivity = AutoAsmSensitivity.STANDARD; + + this._callbacks = { + updateFirmwareVersion: this.updateFirmwareVersion.bind(this), + updateCapabilities: this.updateCapabilities.bind(this), + updateBatteryProps: this.updateBatteryProps.bind(this), + updateCodecIndicator: this.updateCodecIndicator.bind(this), + updateUpscalingIndicator: this.updateUpscalingIndicator.bind(this), + updateAmbientSoundControl: this.updateAmbientSoundControl.bind(this), + updateSpeakToChatEnable: this.updateSpeakToChatEnable.bind(this), + updateSpeakToChatConfig: this.updateSpeakToChatConfig.bind(this), + updateEqualizer: this.updateEqualizer.bind(this), + updateBgmMode: this.updateBgmMode.bind(this), + updateCinemaMode: this.updateCinemaMode.bind(this), + updateAudioSampling: this.updateAudioSampling.bind(this), + updateButtonModesLeftRight: this.updateButtonModesLeftRight.bind(this), + updateAmbientSoundButton: this.updateAmbientSoundButton.bind(this), + updateVoiceNotifications: this.updateVoiceNotifications.bind(this), + updateVoiceNotificationsVolume: this.updateVoiceNotificationsVolume.bind(this), + updatePauseWhenTakenOff: this.updatePauseWhenTakenOff.bind(this), + updateAutomaticPowerOff: this.updateAutomaticPowerOff.bind(this), + updatePairingMode: this.updatePairingMode.bind(this), + updateDeviceList: this.updateDeviceList.bind(this), + updatePlaybackDevice: this.updatePlaybackDevice.bind(this), + updateDeviceActionStatus: this.updateDeviceActionStatus.bind(this), + updateSourceSwitchFixed: this.updateSourceSwitchFixed.bind(this), + }; + + this._batteryDualSupported = modelData.batteryDual ?? false; + this._batteryCaseSupported = modelData.batteryCase ?? false; + this._batterySingleSupported = modelData.batterySingle ?? false; + + this._noNoiseCancellingSupported = modelData.noNoiseCancelling ?? false; + this._ambientSoundControlSupported = modelData.ambientSoundControl ?? false; + this._windNoiseReductionSupported = modelData.windNoiseReduction ?? false; + this._ambientSoundControlNASupported = modelData.ambientSoundControlNA ?? false; + + this._speakToChatEnabledSupported = modelData.speakToChatEnabled ?? false; + this._speakToChatConfigSupported = modelData.speakToChatConfig ?? false; + + this._equalizerSixBandsSupported = modelData.equalizerSixBands ?? false; + this._equalizerTenBandsSupported = modelData.equalizerTenBands ?? false; + this._listeningModeSupported = modelData.listeningMode ?? false; + this._audioUpsamplingSupported = modelData.audioUpsampling ?? false; + + this._buttonModesLeftRight = modelData.buttonModesLeftRight ?? false; + this._ambientSoundControlButtonMode = modelData.ambientSoundControlButtonMode ?? false; + + this._voiceNotificationsSupported = modelData.voiceNotifications ?? false; + this._voiceNotificationsVolumeSupported = modelData.voiceNotificationsVolume ?? false; + + this._pauseWhenTakenOffSupported = modelData.pauseWhenTakenOff ?? false; + this._automaticPowerOffWhenTakenOffSupported = + modelData.automaticPowerOffWhenTakenOff ?? false; + + this._automaticPowerOffByTimeSupported = + modelData.automaticPowerOffByTime ?? false; + + this._dualConnection = modelData.dualConnection ?? false; + this._hasRoutingIndicator = modelData.dualConnection?.hasRoutingIndicator ?? false; + this._hasRoutingControl = modelData.dualConnection?.hasRoutingControl ?? false; + this._hasActiveFix = modelData.dualConnection?.hasActiveFix ?? false; + + this._config.commonIcon = modelData.budsIcon; + this._config.albumArtIcon = modelData.albumArtIcon; + this._config.showSettingsButton = true; + + this._commonIcon = modelData.budsIcon; + + if (this._batteryCaseSupported) + this._caseIcon = `${modelData.case}`; + + if (this._ambientSoundControlSupported) { + this._config.toggle1Title = _('Noise Control'); + this._config.toggle1Button1Icon = 'bbm-anc-off-symbolic'; + this._config.toggle1Button1Name = _('Off'); + this._config.toggle1Button2Icon = 'bbm-transperancy-symbolic'; + this._config.toggle1Button2Name = _('Ambient'); + if (!this._noNoiseCancellingSupported) { + this._config.toggle1Button3Icon = 'bbm-anc-on-symbolic'; + this._config.toggle1Button3Name = _('Noise Cancellation'); + } + this._config.optionsBox1 = ['check-button', 'slider']; + this._config.box1SliderTitle = _('Ambient Level'); + + if (this._ambientSoundControlNASupported) { + this._config.box1CheckButton = [_('Auto Ambient'), _('Focus on Voice')]; + this._config.optionsBox2 = ['check-button', 'radio-button']; + this._config.box2CheckButton = [_('Auto Ambient'), _('Focus on Voice')]; + this._config.box2RadioTitle = _('Auto Ambient Sensitivity'); + this._config.box2RadioButton = [_('Standard'), _('High'), _('Low')]; + } else { + this._config.box1CheckButton = [_('Focus on Voice')]; + } + + /* + if (this._windNoiseReductionSupported) { + this._config.optionsBox3 = ['check-button']; + this._config.box3CheckButton = [_('Wind Noise Reduction')]; + } +*/ + } + + if (this._speakToChatEnabledSupported) { + this._config.toggle2Title = _('Conversation Awareness'); + this._config.toggle2Button1Icon = 'bbm-ca-on-symbolic'; + this._config.toggle2Button1Name = _('On'); + this._config.toggle2Button2Icon = 'bbm-ca-off-symbolic'; + this._config.toggle2Button2Name = _('Off'); + } + + const bandCount = this._equalizerTenBandsSupported ? 10 : 6; + const initialCustomEqValues = Array(bandCount).fill(0); + + this._createDefaultSettings(modelData.name, initialCustomEqValues); + + const devicesList = this._settings.get_strv('sony-list').map(JSON.parse); + + if (devicesList.length === 0 || + !devicesList.some(device => device.path === this._devicePath)) { + this._addPropsToSettings(devicesList); + } else { + validateProperties(this._settings, 'sony-list', devicesList, + this._defaultsDeviceSettings, this._devicePath); + } + + this._updateInitialValues(); + this._monitorSonyListGsettings(); + this._updateIcons(); + + const type = this._usesProtocolV2 ? DeviceTypeSonyV2 : DeviceTypeSonyV1; + const uuid = this._usesProtocolV2 ? SonyUUIDv2 : SonyUUIDv1; + const SocketClass = this._usesProtocolV2 ? SonySocketV2 : SonySocketV1; + const profile = {type, uuid}; + + this._sonySocket = new SocketClass( + this._devicePath, + profileManager, + profile, + modelData, + this._callbacks + ); + } + + _updateIcons() { + this._config.commonIcon = this._commonIcon; + this._config.albumArtIcon = this._commonIcon; + + this._config.battery1ShowOnDisconnect = true; + if (this._batteryDualSupported) { + this._config.battery2ShowOnDisconnect = true; + this._config.battery1Icon = `${this._commonIcon}-left`; + this._config.battery2Icon = `${this._commonIcon}-right`; + } else { + this._config.battery1Icon = this._commonIcon; + } + + if (this._batteryCaseSupported) + this._config.battery3Icon = this._caseIcon; + + this.dataHandler?.setConfig(this._config); + } + + _createDefaultSettings(name, initialCustomEqValues) { + this._defaultsDeviceSettings = { + path: this._devicePath, + name, + alias: this._alias, + icon: this._commonIcon, + 'fw-version': '', + + ...this._batteryCaseSupported && { + 'case': this._caseIcon, + }, + + ...this._speakToChatConfigSupported && { + 's2c-sensitivity': 0, + 's2c-duration': 0, + }, + + ...this._listeningModeSupported && { + 'bgm-mode': 1, + 'bgm-distance': 0, + }, + + ...(this._equalizerSixBandsSupported || this._equalizerTenBandsSupported) && { + 'eq-preset': 0, + 'eq-custom': initialCustomEqValues, + }, + + ...this._audioUpsamplingSupported && { + 'dsee': 0, + }, + + ...this._buttonModesLeftRight && { + 'btn-left': 0, + 'btn-right': 0, + }, + + ...this._ambientSoundControlButtonMode && { + 'amb-btn-mode': 1, + }, + + ...this._voiceNotificationsSupported && { + 'voice-noti': true, + }, + + ...this._voiceNotificationsVolumeSupported && { + 'voice-vol': 0, + }, + + ...this._pauseWhenTakenOffSupported && { + 'pause-takeoff': true, + }, + + ...this._automaticPowerOffWhenTakenOffSupported && { + 'auto-power': true, + }, + + ...this._automaticPowerOffByTimeSupported && { + 'auto-power-time': 0, + }, + + ...this._dualConnection && { + 'dev-mgmt': [], + 'pairing-mode': false, + 'dev-mgmt-action': {seq: 0, action: 0, id: ''}, + }, + + ...(this._hasRoutingIndicator || this._hasRoutingControl) && { + 'active-dev': '', + }, + + ...this._hasActiveFix && { + 'active-fix': false, + }, + }; + } + + _addPropsToSettings(devicesList) { + devicesList.push(this._defaultsDeviceSettings); + this._settings.set_strv('sony-list', devicesList.map(JSON.stringify)); + } + + _updateInitialValues() { + const devicesList = this._settings.get_strv('sony-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + this._commonIcon = this._settingsItems['icon']; + + if (this._batteryCaseSupported) + this._caseIcon = this._settingsItems['case']; + + if (this._speakToChatConfigSupported) { + this._speak2ChatSensitivity = this._settingsItems['s2c-sensitivity']; + this._speak2ChatTimeout = this._settingsItems['s2c-duration']; + } + + if (this._listeningModeSupported) { + this._bgmMode = this._settingsItems['bgm-mode']; + this._bgmDistance = this._settingsItems['bgm-distance']; + } + + if (this._equalizerSixBandsSupported || this._equalizerTenBandsSupported) { + this._eqPreset = this._settingsItems['eq-preset']; + this._eqCustom = this._settingsItems['eq-custom']; + } + + if (this._audioUpsamplingSupported) + this._audioUpsampling = this._settingsItems['dsee']; + + + if (this._buttonModesLeftRight) { + this._btnLeft = this._settingsItems['btn-left']; + this._btnRight = this._settingsItems['btn-right']; + } + + if (this._ambientSoundControlButtonMode) + this._ambientButtonMode = this._settingsItems['amb-btn-mode']; + + + if (this._voiceNotificationsSupported) + this._voiceNoti = this._settingsItems['voice-noti']; + + + if (this._voiceNotificationsVolumeSupported) + this._voiceVolume = this._settingsItems['voice-vol']; + + + if (this._pauseWhenTakenOffSupported) + this._pauseWhenTakenOff = this._settingsItems['pause-takeoff']; + + + if (this._automaticPowerOffWhenTakenOffSupported) + this._autoPowerOff = this._settingsItems['auto-power']; + + + if (this._automaticPowerOffByTimeSupported) + this._autoPowerOffTime = this._settingsItems['auto-power-time']; + + if (this._dualConnection) { + this._pairingMode = this._settingsItems['pairing-mode']; + this._devMgmtAction = this._settingsItems['dev-mgmt-action']; + + if (this._hasRoutingIndicator || this._hasRoutingControl) { + this._activeDevice = this._settingsItems['active-dev']; + if (this._hasActiveFix) + this._activeDeviceFixed = this._settingsItems['active-fix']; + } + } + } + + _updateGsettingsProps() { + const devicesList = this._settings.get_strv('sony-list').map(JSON.parse); + const existingPathIndex = devicesList.findIndex(item => item.path === this._devicePath); + if (existingPathIndex === -1) + return; + + this._settingsItems = devicesList[existingPathIndex]; + + const icon = this._settingsItems['icon']; + if (this._commonIcon !== icon) { + this._commonIcon = icon; + this._updateIcons(); + } + + if (this._batteryCaseSupported) { + const caseIcon = this._settingsItems['case']; + if (this._caseIcon !== caseIcon) { + this._caseIcon = caseIcon; + this._updateIcons(); + } + } + + if (this._speakToChatConfigSupported) { + const sensitivity = this._settingsItems['s2c-sensitivity']; + if (this._speak2ChatSensitivity !== sensitivity) { + this._speak2ChatSensitivity = sensitivity; + this._setSpeakToChatConfig(); + } + } + + if (this._speakToChatConfigSupported) { + const timeout = this._settingsItems['s2c-duration']; + if (this._speak2ChatTimeout !== timeout) { + this._speak2ChatTimeout = timeout; + this._setSpeakToChatConfig(); + } + } + + if (this._listeningModeSupported) { + const bgmMode = this._settingsItems['bgm-mode']; + if (this._bgmMode !== bgmMode) { + this._bgmMode = bgmMode; + this._setBgmMode(); + } + + const bgmDistance = this._settingsItems['bgm-distance']; + if (this._bgmDistance !== bgmDistance) { + this._bgmDistance = bgmDistance; + this._setBgmMode(); + } + } + + if (this._equalizerSixBandsSupported || this._equalizerTenBandsSupported) { + const preset = this._settingsItems['eq-preset']; + if (this._eqPreset !== preset) { + this._eqPreset = preset; + this._setEqualizerPreset(); + } + + const customBands = this._settingsItems['eq-custom']; + if (!this._eqCustom || !isArrayEqual(customBands, this._eqCustom)) { + this._eqCustom = customBands; + this._setEqualizerCustomBands(); + } + } + + if (this._audioUpsamplingSupported) { + const dsee = this._settingsItems['dsee']; + if (this._audioUpsampling !== dsee) { + this._audioUpsampling = dsee; + this._setAudioUpsampling(); + } + } + + if (this._buttonModesLeftRight) { + const btnLeft = this._settingsItems['btn-left']; + const btnRight = this._settingsItems['btn-right']; + if (this._btnLeft !== btnLeft || this._btnRight !== btnRight) { + this._btnLeft = btnLeft; + this._btnRight = btnRight; + this._setButtonModes(); + } + } + + if (this._ambientSoundControlButtonMode) { + const ambBtnMode = this._settingsItems['amb-btn-mode']; + if (this._ambientButtonMode !== ambBtnMode) { + this._ambientButtonMode = ambBtnMode; + this._setAmbientButtonMode(); + } + } + + if (this._voiceNotificationsSupported) { + const noti = this._settingsItems['voice-noti']; + if (this._voiceNoti !== noti) { + this._voiceNoti = noti; + this._setVoiceNotifications(); + } + } + + if (this._voiceNotificationsVolumeSupported) { + const notiVol = this._settingsItems['voice-vol']; + if (this._voiceVolume !== notiVol) { + this._voiceVolume = notiVol; + this._setVoiceNotificationsVolume(); + } + } + + + if (this._pauseWhenTakenOffSupported) { + const pause = this._settingsItems['pause-takeoff']; + if (this._pauseWhenTakenOff !== pause) { + this._pauseWhenTakenOff = pause; + this._setPauseWhenTakenOff(); + } + } + + if (this._automaticPowerOffWhenTakenOffSupported) { + const autoPower = this._settingsItems['auto-power']; + if (this._autoPowerOff !== autoPower) { + this._autoPowerOff = autoPower; + this._setAutoPowerOff(); + } + } + + if (this._automaticPowerOffByTimeSupported) { + const autoPowerTime = this._settingsItems['auto-power-time']; + if (this._autoPowerOffTime !== autoPowerTime) { + this._autoPowerOffTime = autoPowerTime; + this._setAutoPowerOff(); + } + } + + if (this._dualConnection) { + const pairingMode = this._settingsItems['pairing-mode']; + if (this._pairingMode !== pairingMode) { + this._pairingMode = pairingMode; + this._setPairingMode(pairingMode); + } + + if (this._hasActiveFix) { + const activeFixed = this._settingsItems['active-fix']; + if (this._activeDeviceFixed !== activeFixed) { + this._activeDeviceFixed = activeFixed; + this._setSourceSwitchFixed(activeFixed); + } + } + + const devMgmtAction = this._settingsItems['dev-mgmt-action']; + if (this._devMgmtAction.seq !== devMgmtAction.seq) { + this._devMgmtAction = devMgmtAction; + + switch (devMgmtAction.action) { + case DeviceManagementAction.Connect: + this._connectBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Disconnect: + this._disconnectBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Remove: + this._removeBTDevice(devMgmtAction.id); + break; + + case DeviceManagementAction.Routing: + if (this._hasRoutingControl) + this._setRoutingBTDevice(devMgmtAction.id); + break; + } + } + } + } + + _monitorSonyListGsettings() { + this._settingsHandlerId = this._settings?.connect('changed::sony-list', () => { + if (this._ignoreGsettingsChange) + return; + + this._updateGsettingsProps(); + }); + } + + _updateGsettings() { + this._ignoreGsettingsChange = true; + + const currentList = this._settings.get_strv('sony-list').map(JSON.parse); + const index = currentList.findIndex(d => d.path === this._devicePath); + + if (index !== -1) { + currentList[index] = this._settingsItems; + this._settings.set_strv('sony-list', currentList.map(JSON.stringify)); + } + + this._ignoreGsettingsChange = false; + } + + _startConfiguration(battInfo) { + const bat1level = battInfo.battery1Level ?? 0; + const bat2level = battInfo.battery2Level ?? 0; + const bat3level = battInfo.battery3Level ?? 0; + + if (bat1level <= 0 && bat2level <= 0 && bat3level <= 0) + return; + + + this._battInfoRecieved = true; + + this.dataHandler = new DataHandler(this._config, this._props); + + this.updateDeviceMapCb(this._devicePath, this.dataHandler); + + this._props.toggle1Visible = this._ambientSoundControlSupported; + this._props.toggle2Visible = this._speakToChatEnabledSupported; + + this._dataHandlerId = this.dataHandler.connect( + 'ui-action', (o, command, value) => { + if (command === 'toggle1State') + this._toggle1ButtonClicked(value); + + if (command === 'toggle2State') + this._toggle2ButtonClicked(value); + + if (command === 'box1SliderValue') + this._box1SliderValueChanged(value); + + if (command === 'box1CheckButton1State') + this._box1CheckButton1StateChanged(value); + + if (command === 'box2CheckButton1State') + this._box2CheckButton1StateChanged(value); + + if (command === 'box1CheckButton2State') + this._box1CheckButton2StateChanged(value); + + if (command === 'box2CheckButton2State') + this._box2CheckButton2StateChanged(value); + + if (command === 'box2RadioButtonState') + this._box2RadioButtonStateChanged(value); + + if (command === 'settingsButtonClicked') + this._settingsButtonClicked(); + } + ); + } + + updateFirmwareVersion(fwVersion) { + this._settingsItems['fw-version'] = fwVersion; + this._updateGsettings(); + } + + updateCapabilities(supportsCodecIndicator, supportsUpscalingIndicator) { + this._supportsCodecIndicator = supportsCodecIndicator; + this._supportsUpscalingIndicator = + supportsUpscalingIndicator && this._audioUpsamplingSupported; + + this._label1IsUpscaling = false; + + if (supportsCodecIndicator && this._supportsUpscalingIndicator) { + this._config.labelIndicatorEnabled = 2; + this._label1IsUpscaling = false; + } else if (supportsCodecIndicator || this._supportsUpscalingIndicator) { + this._config.labelIndicatorEnabled = 1; + this._label1IsUpscaling = !supportsCodecIndicator; + } else { + this._config.labelIndicatorEnabled = 0; + } + this.dataHandler?.setConfig(this._config); + } + + updateCodecIndicator(codec) { + if (!this._supportsCodecIndicator) + return; + + let text = ''; + + switch (codec) { + case AudioCodec.SBC: + text = 'SBC'; + break; + case AudioCodec.AAC: + text = 'AAC'; + break; + case AudioCodec.LDAC: + text = 'LDAC'; + break; + case AudioCodec.APT_X: + text = 'aptX'; + break; + case AudioCodec.APT_X_HD: + text = 'aptX HD'; + break; + case AudioCodec.LC3: + text = 'LC3'; + break; + default: + text = ''; + break; + } + + if (!this._label1IsUpscaling) + this._props.labelIndicator1 = text; + + this.dataHandler?.setProps(this._props); + } + + updateUpscalingIndicator(type) { + if (!this._supportsUpscalingIndicator) + return; + + let text = ''; + + switch (type) { + case DseeType.DSEE_HX: + text = 'DSEE HX'; + break; + case DseeType.DSEE: + text = 'DSEE'; + break; + case DseeType.DSEE_HX_AI: + text = 'DSEE EX'; + break; + case DseeType.DSEE_ULTIMATE: + text = 'DSEE EX'; + break; + default: + text = ''; + break; + } + + if (!this._audioUpsampling) + text = ''; + + if (this._label1IsUpscaling) + this._props.labelIndicator1 = text; + else + this._props.labelIndicator2 = text; + + this.dataHandler?.setProps(this._props); + } + + updateBatteryProps(props) { + this._props = {...this._props, ...props}; + if (!this._battInfoRecieved) + this._startConfiguration(props); + + if (this._batteryDualSupported) + this._props.computedBatteryLevel = buds2to1BatteryLevel(props); + else + this._props.computedBatteryLevel = props.battery1Level; + + + this.dataHandler?.setProps(this._props); + } + + updateAmbientSoundControl(mode, focusOnVoiceState, level, naMode, naSensitivity) { + if (!this._ambientSoundControlSupported) + return; + + this._ambientMode = mode; + this._focusOnVoiceState = focusOnVoiceState; + this._ambientLevel = level; + this._naMode = naMode; + this._naSensitivity = naSensitivity; + + if (mode === AmbientSoundMode.ANC_OFF) { + this._props.toggle1State = 1; + this._props.optionsBoxVisible = 0; + } else if (mode === AmbientSoundMode.AMBIENT) { + this._props.toggle1State = 2; + this._props.optionsBoxVisible = this._ambientSoundControlNASupported && naMode ? 2 : 1; + } else if (mode === AmbientSoundMode.ANC_ON && !this._noNoiseCancellingSupported) { + this._props.toggle1State = 3; + this._props.optionsBoxVisible = 0; + } + + if (this._ambientSoundControlNASupported) { + this._props.box1CheckButton1State = naMode ? 1 : 0; + this._props.box1CheckButton2State = focusOnVoiceState ? 1 : 0; + this._props.box2CheckButton1State = naMode ? 1 : 0; + this._props.box2CheckButton2State = focusOnVoiceState ? 1 : 0; + this._props.box2RadioButtonState = naSensitivity + 1; + } else { + this._props.box1CheckButton1State = focusOnVoiceState ? 1 : 0; + } + + this._props.box1SliderValue = level * 100 / 20; + + this.dataHandler?.setProps(this._props); + } + + _toggle1ButtonClicked(index) { + if (!this._ambientSoundControlSupported) + return; + + if (index === 1) { + this._ambientMode = AmbientSoundMode.ANC_OFF; + this._props.optionsBoxVisible = 0; + this._props.toggle1State = 1; + } else if (index === 2) { + this._ambientMode = AmbientSoundMode.AMBIENT; + this._props.optionsBoxVisible = this._ambientSoundControlNASupported && + this._naMode ? 2 : 1; + this._props.toggle1State = 2; + } else if (index === 3) { + this._ambientMode = AmbientSoundMode.ANC_ON; + this._props.optionsBoxVisible = 0; + this._props.toggle1State = 3; + } + this.dataHandler?.setProps(this._props); + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + + _box1SliderValueChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + const ambientLevel = Math.round(value / 100 * 20); + if (this._ambientLevel !== ambientLevel) { + this._ambientLevel = ambientLevel; + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + _box1CheckButton1StateChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + let sendPacket = false; + if (this._ambientSoundControlNASupported) { + this._props.optionsBoxVisible = value === 1 ? 2 : 1; + this._props.box1CheckButton1State = value; + this._props.box2CheckButton1State = value; + this.dataHandler?.setProps(this._props); + + const naMode = value === 1; + if (naMode !== this._naMode) { + this._naMode = naMode; + sendPacket = true; + } + } else { + const focusOnVoice = value === 1; + if (focusOnVoice !== this._focusOnVoiceState) { + this._focusOnVoiceState = focusOnVoice; + sendPacket = true; + } + } + + if (sendPacket) { + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + _box2CheckButton1StateChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + let sendPacket = false; + this._props.optionsBoxVisible = value === 1 ? 2 : 1; + this._props.box1CheckButton1State = value; + this._props.box2CheckButton1State = value; + this.dataHandler?.setProps(this._props); + + const naMode = value === 1; + if (naMode !== this._naMode) { + this._naMode = naMode; + sendPacket = true; + } + + if (sendPacket) { + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + _box1CheckButton2StateChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + let sendPacket = false; + this._props.box1CheckButton2State = value; + this._props.box2CheckButton2State = value; + this.dataHandler?.setProps(this._props); + + const focusOnVoice = value === 1; + if (focusOnVoice !== this._focusOnVoiceState) { + this._focusOnVoiceState = focusOnVoice; + sendPacket = true; + } + + if (sendPacket) { + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + _box2CheckButton2StateChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + let sendPacket = false; + this._props.box1CheckButton2State = value; + this._props.box2CheckButton2State = value; + this.dataHandler?.setProps(this._props); + + const focusOnVoice = value === 1; + if (focusOnVoice !== this._focusOnVoiceState) { + this._focusOnVoiceState = focusOnVoice; + sendPacket = true; + } + + if (sendPacket) { + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + _box2RadioButtonStateChanged(value) { + if (!this._ambientSoundControlSupported) + return; + + const naSensitivity = value - 1; + + if (naSensitivity !== this._naSensitivity) { + this._naSensitivity = naSensitivity; + this._sonySocket.setAmbientSoundControl(this._ambientMode, this._focusOnVoiceState, + this._ambientLevel, this._naMode, this._naSensitivity); + } + } + + updateSpeakToChatEnable(enabled) { + if (!this._speakToChatEnabledSupported) + return; + + this._props.toggle2State = enabled ? 1 : 2; + this.dataHandler?.setProps(this._props); + } + + _toggle2ButtonClicked(index) { + if (!this._speakToChatEnabledSupported) + return; + + if (index === 1) + this._sonySocket.setSpeakToChatEnabled(true); + else if (index === 2) + this._sonySocket.setSpeakToChatEnabled(false); + } + + updateSpeakToChatConfig(speak2ChatSensitivity, speak2ChatTimeout) { + if (!this._speakToChatConfigSupported) + return; + + if (this._speak2ChatSensitivity !== speak2ChatSensitivity || + this._speak2ChatTimeout !== speak2ChatTimeout) { + this._speak2ChatSensitivity = speak2ChatSensitivity; + this._speak2ChatTimeout = speak2ChatTimeout; + this._settingsItems['s2c-sensitivity'] = speak2ChatSensitivity; + this._settingsItems['s2c-duration'] = speak2ChatTimeout; + this._updateGsettings(); + } + } + + _setSpeakToChatConfig() { + if (!this._speakToChatConfigSupported) + return; + + this._sonySocket.setSpeakToChatConfig(this._speak2ChatSensitivity, + this._speak2ChatTimeout); + } + + updateBgmMode(enable, distance) { + if (!this._listeningModeSupported) + return; + + const mode = enable ? ListeningMode.BGM : ListeningMode.STANDARD; + + if (this._bgmMode !== mode || this._bgmDistance !== distance) { + this._bgmMode = mode; + this._bgmDistance = distance; + + this._settingsItems['bgm-mode'] = mode; + this._settingsItems['bgm-distance'] = distance; + this._updateGsettings(); + } + } + + updateCinemaMode(enable) { + if (!this._listeningModeSupported && !enable) + return; + + const mode = ListeningMode.CINEMA; + if (this._bgmMode !== mode) { + this._bgmMode = mode; + this._settingsItems['bgm-mode'] = mode; + this._updateGsettings(); + } + } + + _setBgmMode() { + if (!this._listeningModeSupported) + return; + + this._sonySocket.setListeningMode(this._bgmMode, this._bgmDistance); + } + + updateEqualizer(presetCode, customBands) { + if (!this._equalizerSixBandsSupported && !this._equalizerTenBandsSupported) + return; + + if (this._eqPreset !== presetCode || !isArrayEqual(this._eqCustom, customBands)) { + this._eqPreset = presetCode; + this._eqCustom = customBands; + this._settingsItems['eq-preset'] = presetCode; + this._settingsItems['eq-custom'] = customBands; + this._updateGsettings(); + } + } + + _setEqualizerPreset() { + if (!this._equalizerSixBandsSupported && !this._equalizerTenBandsSupported) + return; + + if (this._eqPreset === undefined) + return; + + this._sonySocket.setEqualizerPreset(this._eqPreset); + } + + _setEqualizerCustomBands() { + if (this._eqPreset === undefined || this._eqCustom === undefined) + return; + + if (this._equalizerSixBandsSupported && this._eqCustom.length === 6 || + this._equalizerTenBandsSupported && this._eqCustom.length === 10) + this._sonySocket.setEqualizer(this._eqPreset, this._eqCustom); + } + + updateAudioSampling(enabled) { + if (!this._audioUpsamplingSupported) + return; + + this._audioUpsampling = enabled ? 1 : 0; + + if (this._settingsItems) { + this._settingsItems['dsee'] = this._audioUpsampling; + this._updateGsettings(); + } + } + + _setAudioUpsampling() { + if (!this._audioUpsamplingSupported) + return; + + if (this._audioUpsampling !== undefined) { + const enabled = this._audioUpsampling === 1; + this._sonySocket.setAudioUpsampling(enabled); + } + } + + updateButtonModesLeftRight(leftMode, rightMode) { + if (!this._buttonModesLeftRight) + return; + + if (this._btnLeft !== leftMode || this._btnRight !== rightMode) { + this._btnLeft = leftMode; + this._btnRight = rightMode; + this._settingsItems['btn-left'] = leftMode; + this._settingsItems['btn-right'] = rightMode; + this._updateGsettings(); + } + } + + _setButtonModes() { + if (!this._buttonModesLeftRight) + return; + + this._sonySocket.setButtonModesLeftRight(this._btnLeft, this._btnRight); + } + + updateAmbientSoundButton(mode) { + if (!this._ambientSoundControlButtonMode) + return; + + if (this._ambientButtonMode !== mode) { + this._ambientButtonMode = mode; + this._settingsItems['amb-btn-mode'] = this._ambientButtonMode; + this._updateGsettings(); + } + } + + _setAmbientButtonMode() { + if (!this._ambientSoundControlButtonMode) + return; + + this._sonySocket.setAmbientSoundButton(this._ambientButtonMode); + } + + updateVoiceNotifications(enabled) { + if (!this._voiceNotificationsSupported) + return; + + if (this._voiceNoti !== enabled) { + this._voiceNoti = enabled; + this._settingsItems['voice-noti'] = enabled; + this._updateGsettings(); + } + } + + _setVoiceNotifications() { + if (!this._voiceNotificationsSupported) + return; + + this._sonySocket.setVoiceNotifications(this._voiceNoti); + } + + + updateVoiceNotificationsVolume(vol) { + if (!this._voiceNotificationsVolumeSupported) + return; + + if (this._voiceVolume !== vol) { + this._voiceVolume = vol; + this._settingsItems['voice-vol'] = vol; + this._updateGsettings(); + } + } + + _setVoiceNotificationsVolume() { + if (!this._voiceNotificationsVolumeSupported) + return; + + this._sonySocket.setVoiceNotificationsVolume(this._voiceVolume); + } + + updatePauseWhenTakenOff(enabled) { + if (!this._pauseWhenTakenOffSupported) + return; + + if (this._pauseWhenTakenOff !== enabled) { + this._pauseWhenTakenOff = enabled; + this._settingsItems['pause-takeoff'] = enabled; + this._updateGsettings(); + } + } + + _setPauseWhenTakenOff() { + if (!this._pauseWhenTakenOffSupported) + return; + + this._sonySocket.setPauseWhenTakenOff(this._pauseWhenTakenOff); + } + + updateAutomaticPowerOff(enabled, time) { + if (!this._automaticPowerOffWhenTakenOffSupported) + return; + + if (this._autoPowerOff !== enabled || + this._automaticPowerOffByTimeSupported && this._autoPowerOffTime !== time) { + this._autoPowerOff = enabled; + + if (this._automaticPowerOffByTimeSupported) + this._autoPowerOffTime = time; + + this._settingsItems['auto-power'] = enabled; + + if (this._automaticPowerOffByTimeSupported) + this._settingsItems['auto-power-time'] = time; + + this._updateGsettings(); + } + } + + _setAutoPowerOff() { + if (!this._automaticPowerOffWhenTakenOffSupported) + return; + + const time = this._automaticPowerOffByTimeSupported ? this._autoPowerOffTime : 0; + + this._sonySocket.setAutomaticPowerOff(this._autoPowerOff, time); + } + + updatePairingMode(enabled) { + this._log.info(`updatePairingMode enabled: ${enabled}`); + if (this._pairingMode !== enabled) { + this._pairingMode = enabled; + this._settingsItems['pairing-mode'] = enabled; + this._updateGsettings(); + } + } + + _setPairingMode(enabled) { + this._sonySocket?.setPairingMode(enabled); + } + + updateDeviceList(devices, routingMac) { + this._deviceInfo = devices.map(device => ({ + id: device.mac, + name: device.name, + connected: device.connected, + state: BtDeviceState.Ready, + })); + + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._activeDevice = routingMac; + this._settingsItems['active-dev'] = routingMac; + this._updateGsettings(); + } + + _startBTTimeout(id, operation) { + const timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => { + const pending = this._pendingBTOperations.get(id); + + if (!pending) + return GLib.SOURCE_REMOVE; + + const device = this._deviceInfo.find(info => info.id === id); + + if (device) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + } + + this._pendingBTOperations.delete(id); + + return GLib.SOURCE_REMOVE; + }); + + this._pendingBTOperations.set(id, {operation, timeoutId}); + } + + + _stopBTTimeout(id) { + const pending = this._pendingBTOperations.get(id); + if (!pending) + return; + + if (pending.timeoutId) + GLib.Source.remove(pending.timeoutId); + + this._pendingBTOperations.delete(id); + } + + updateDeviceActionStatus(action, result, mac) { + const device = this._deviceInfo.find(info => info.id === mac); + if (!device) + return; + + let inProgress = false; + let isReady = false; + if (result === 0x02 || result === 0x12 || result === 0x22 || + result === 0x03 || result === 0x13 || result === 0x23) + inProgress = true; + + if (result === 0x00 || result === 0x10 || result === 0x20) + isReady = true; + + if (inProgress || isReady) { + if (action === 0x01) { + device.connected = true; + device.state = inProgress ? BtDeviceState.Processing : BtDeviceState.Ready; + } else if (action === 0x00) { + device.connected = false; + device.state = BtDeviceState.Ready; + } else if (action === 0x02) { + this._deviceInfo = this._deviceInfo.filter(info => info.id !== mac); + } + + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._stopBTTimeout(mac); + return; + } + + if (result === 0x01 || result === 0x03 || result === 0x11 || result === 0x13 || + result === 0x21 || result === 0x23) { + device.state = BtDeviceState.Ready; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + this._stopBTTimeout(mac); + } + } + + _connectBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + + this._startBTTimeout(id, 'connect'); + this._sonySocket?.connectDevice(id); + } + + _disconnectBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + + this._startBTTimeout(id, 'disconnect'); + this._sonySocket?.disconnectDevice(id); + } + + _removeBTDevice(id) { + const device = this._deviceInfo.find(info => info.id === id); + if (!device) + return; + + if (this._pendingBTOperations.has(id)) + return; + + device.state = BtDeviceState.Processing; + this._settingsItems['dev-mgmt'] = this._deviceInfo; + this._updateGsettings(); + + this._startBTTimeout(id, 'remove'); + this._sonySocket?.unpairDevice(id); + } + + updatePlaybackDevice(id) { + if (this._activeDevice !== id) { + this._activeDevice = id; + this._settingsItems['active-dev'] = id; + this._updateGsettings(); + } + } + + _setRoutingBTDevice(id) { + this._sonySocket?.setSourceSwitch(id); + } + + updateSourceSwitchFixed(enabled) { + if (this._activeDeviceFixed !== enabled) { + this._activeDeviceFixed = enabled; + this._settingsItems['active-fix'] = enabled; + this._updateGsettings(); + } + } + + _setSourceSwitchFixed(enabled) { + this._sonySocket?.setSourceSwitchFixed(enabled); + } + + _settingsButtonClicked() { + this._configureWindowLauncherCancellable = new Gio.Cancellable(); + launchConfigureWindow(this._devicePath, 'sony', this._extPath, + this._configureWindowLauncherCancellable); + this._configureWindowLauncherCancellable = null; + } + + destroy() { + this._configureWindowLauncherCancellable?.cancel(); + this._configureWindowLauncherCancellable = null; + this._bluezDeviceProxy = null; + this._sonySocket?.destroy(); + this._sonySocket = null; + + for (const {timeoutId} of this._pendingBTOperations.values()) + GLib.Source.remove(timeoutId); + + this._pendingBTOperations.clear(); + + if (this._dataHandlerId) + this.dataHandler?.disconnect(this._dataHandlerId); + this._dataHandlerId = null; + this.dataHandler = null; + if (this._settingsHandlerId) + this._settings?.disconnect(this._settingsHandlerId); + this._settingsHandlerId = null; + this._settings = null; + this._battInfoRecieved = false; + } +}); diff --git a/bbm/lib/devices/sony/sonySocketBase.js b/bbm/lib/devices/sony/sonySocketBase.js new file mode 100644 index 0000000000..573472a4c1 --- /dev/null +++ b/bbm/lib/devices/sony/sonySocketBase.js @@ -0,0 +1,369 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SocketHandler} from '../socketByProfile.js'; + +import {Checksum, MessageType} from './sonyConfig.js'; + +export const SonySocketBase = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SonySocketBase', + Signals: {'ack-received': {param_types: [GObject.TYPE_STRING]}}, +}, class SonySocketBase extends SocketHandler { + _init(devicePath, profileManager, profile) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SonySocketBase-${identifier}`; + this._log = createLogger(tag); + this._messageQueue = []; + this._initComplete = false; + this._processingQueue = false; + this._currentMessage = null; + this.pendingRequestQueue = []; + this._pendingRequestRetries = 4; + this._seq = 0; + this._frameBuf = new Uint8Array(0); + } + + addMessageQueue(type, payload, ackType = 'unknown') { + this._messageQueue.push({type, payload, ackType}); + + if (!this._processingQueue) + this._processNextQueuedMessage(); + } + + _processNextQueuedMessage() { + if (this._messageQueue.length === 0) { + this._processingQueue = false; + return; + } + + this._processingQueue = true; + this._currentMessage = this._messageQueue.shift(); + this._sendAndWaitAck(); + } + + _sendAndWaitAck() { + if (!this._currentMessage) + return; + + const {type, payload} = this._currentMessage; + if (this._currentMessage.ackType === 'EndOfGetMessage') { + this._currentMessage = null; + this._checkForPendingRequest(); + this._processNextQueuedMessage(); + return; + } + + this._encodeSonyMessage(type, payload); + + if (this._ackTimeoutId) { + GLib.source_remove(this._ackTimeoutId); + this._ackTimeoutId = null; + } + + this._ackTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + const ackType = this._currentMessage?.ackType ?? 'Unknown'; + this._log.info(`ACK not received after 300ms for ${ackType}, continuing.`); + + this._currentMessage = null; + + if (this._messageQueue.length === 0) + this._processingQueue = false; + else + this._processNextQueuedMessage(); + + this._ackTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _checkForPendingRequest() { + this._log.info(`PendingRequestQueue = ${this.pendingRequestQueue}`); + if (this._pendingRequestRetries > 0 && this.pendingRequestQueue?.length > 0) { + this._pendingRequestRetries--; + + if (this._pendingTimeoutId) + GLib.source_remove(this._pendingTimeoutId); + + this._pendingTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 4, () => { + this.resendPendingRequest(); + this._pendingTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + } else { + this._pendingRequestRetries = 0; + delete this.pendingRequestQueue; + } + } + + _onAcknowledgeReceived(_, ackType) { + if (ackType !== 'ack') + return; + + const msg = this._currentMessage; + + if (!msg || !msg.ackType) + this._log.info('ACK Received.'); + else + this._log.info(`ACK Received for ${msg.ackType}`); + + if (msg?.ackType && this.pendingRequestQueue?.length > 0) { + const index = this.pendingRequestQueue.indexOf(msg.ackType); + if (index !== -1) + this.pendingRequestQueue.splice(index, 1); + } + + if (this._ackTimeoutId) { + GLib.source_remove(this._ackTimeoutId); + this._ackTimeoutId = null; + } + + this._currentMessage = null; + + if (this._messageQueue.length === 0) + this._processingQueue = false; + else + this._processNextQueuedMessage(); + } + + _encodeSonyMessage(messageType, payloadArr, seq) { + const len = payloadArr.length; + const headerBuf = new Uint8Array(6 + len); + let sequence; + if (seq !== undefined) { + sequence = seq; + } else { + sequence = this._seq; + this._seq = 1 - this._seq; + } + + headerBuf[0] = messageType; + headerBuf[1] = sequence; + headerBuf[2] = len >>> 24 & 0xFF; + headerBuf[3] = len >>> 16 & 0xFF; + headerBuf[4] = len >>> 8 & 0xFF; + headerBuf[5] = len & 0xFF; + headerBuf.set(payloadArr, 6); + + const chksum = this._calcChecksum(headerBuf); + const bodyEsc = this._escapeBytes(headerBuf); + const chkEsc = this._escapeBytes(new Uint8Array([chksum])); + this.sendMessage( + Uint8Array.from([Checksum.HEADER, ...bodyEsc, ...chkEsc, Checksum.TRAILER])); + } + + _decodeSonyMessage(rawBytes) { + if (rawBytes[0] !== Checksum.HEADER) { + this._log.error(`Invalid header: ${rawBytes[0]}`); + return null; + } + + if (rawBytes.at(-1) !== Checksum.TRAILER) { + this._log.error(`Invalid trailer: ${rawBytes.at(-1)}`); + return null; + } + + const unesc = this._unescapeBytes(rawBytes); + const lenAll = unesc.length; + const chksum = unesc[lenAll - 2]; + const exp = this._calcChecksum(unesc.subarray(1, lenAll - 2)); + if (chksum !== exp) { + this._log.error(`Checksum mismatch ${chksum} != ${exp}`); + return null; + } + + const payloadLen = unesc[3] << 24 | unesc[4] << 16 | unesc[5] << 8 | unesc[6]; + const payload = unesc.subarray(7, 7 + payloadLen); + return {messageType: unesc[1], sequence: unesc[2], payload}; + } + + _calcChecksum(buf) { + let sum = 0; + for (const b of buf) + sum = sum + b & 0xFF; + return sum; + } + + _escapeBytes(buf) { + const out = []; + for (const b of buf) { + if (b === Checksum.HEADER || b === Checksum.TRAILER || b === Checksum.ESCAPE) + out.push(Checksum.ESCAPE, b & Checksum.ESCAPE_MASK); + else + out.push(b); + } + return new Uint8Array(out); + } + + _unescapeBytes(buf) { + const out = []; + for (let i = 0; i < buf.length; i++) { + if (buf[i] === Checksum.ESCAPE) { + i++; + out.push(buf[i] | ~Checksum.ESCAPE_MASK); + } else { + out.push(buf[i]); + } + } + return new Uint8Array(out); + } + + waitForResponse(ackType, resendFn, timeoutSeconds = 5, maxRetries = 3) { + return new Promise((resolve, reject) => { + let retries = 0; + + const attempt = () => { + if (retries >= maxRetries) { + this._log.error(`Failed to receive ${ackType} after ${maxRetries} retries`); + if (this._responseSignalId) + this.disconnect(this._responseSignalId); + this._responseSignalId = null; + + if (this._responseTimeoutId) + GLib.source_remove(this._responseTimeoutId); + this._responseTimeoutId = null; + + reject(new Error(`Timeout waiting for ${ackType}`)); + return; + } + + retries++; + this._log.info(`Waiting for '${ackType}', attempt ${retries}`); + + if (this._responseSignalId) + this.disconnect(this._responseSignalId); + + this._responseSignalId = this.connect('ack-received', (_, receivedAck) => { + if (receivedAck === ackType) { + this._log.info(`'${ackType}' received`); + if (this._responseTimeoutId) { + GLib.source_remove(this._responseTimeoutId); + this._responseTimeoutId = null; + } + if (this._responseSignalId) { + this.disconnect(this._responseSignalId); + this._responseSignalId = null; + } + resolve(); + } + }); + + resendFn(); + + if (this._responseTimeoutId) + GLib.source_remove(this._responseTimeoutId); + + this._responseTimeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, + timeoutSeconds, () => { + this._log.info(`'${ackType}' not received after ` + + `${timeoutSeconds}s, retrying...`); + attempt(); + return GLib.SOURCE_REMOVE; + } + ); + }; + + attempt(); + }); + } + + _encodeAck(seq) { + this._encodeSonyMessage(MessageType.ACK, [], 1 - seq); + } + + tagEndOfGetMessage() { + this._log.info('TAG EndOfGetMessage:'); + + const payload = [0x00]; + const ackType = 'EndOfGetMessage'; + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + processData(chunk) { + const buf = new Uint8Array(this._frameBuf.length + chunk.length); + buf.set(this._frameBuf, 0); + buf.set(chunk, this._frameBuf.length); + + let frameStart = -1; + const frames = []; + for (let i = 0; i < buf.length; i++) { + const b = buf[i]; + + if (frameStart < 0) { + if (b === Checksum.HEADER) + frameStart = i; + } else if (b === Checksum.TRAILER) { + frames.push(buf.slice(frameStart, i + 1)); + frameStart = -1; + } + } + + if (frameStart >= 0) + this._frameBuf = buf.slice(frameStart); + else + this._frameBuf = new Uint8Array(0); + + + for (const frame of frames) + this._parseData(frame); + } + + _parseData(rawData) { + try { + const data = this._decodeSonyMessage(rawData); + if (!data) + return; + const {messageType, sequence, payload} = data; + + if (messageType === MessageType.ACK) { + this.emit('ack-received', 'ack'); + return; + } + + if (messageType === MessageType.COMMAND_1 || messageType === MessageType.COMMAND_2) + this._encodeAck(sequence); + + if (messageType === MessageType.COMMAND_1) + this.handleMessageType1(payload); + + if (messageType === MessageType.COMMAND_2) + this.handleMessageType2(payload); + } catch (e) { + this._log.error('Failed to process socket data', e); + } + } + + postConnectInitialization() { + this.ackSignalId = this.connect('ack-received', this._onAcknowledgeReceived.bind(this)); + this.sendInit(); + } + + destroy() { + if (this.ackSignalId) + this.disconnect(this.ackSignalId); + this.ackSignalId = null; + + if (this._responseSignalId) + this.disconnect(this._responseSignalId); + this._responseSignalId = null; + + if (this._responseTimeoutId) + GLib.source_remove(this._responseTimeoutId); + this._responseTimeoutId = null; + + if (this._pendingTimeoutId) + GLib.source_remove(this._pendingTimeoutId); + this._pendingTimeoutId = null; + + if (this._ackTimeoutId) + GLib.source_remove(this._ackTimeoutId); + this._ackTimeoutId = null; + + this._seq = 0; + + super.destroy(); + } +}); + diff --git a/bbm/lib/devices/sony/sonySocketV1.js b/bbm/lib/devices/sony/sonySocketV1.js new file mode 100644 index 0000000000..c4fbb81d91 --- /dev/null +++ b/bbm/lib/devices/sony/sonySocketV1.js @@ -0,0 +1,1047 @@ +'use strict'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SonySocketBase} from './sonySocketBase.js'; +import {isValidByte, booleanFromByte} from '../deviceUtils.js'; + +import { + MessageType, PayloadTypeV1T1 as PTV1T1, ValueType, FunctionTypeV1T1, BatteryType, AudioCodec, + DseeType, AmbientSoundMode, Speak2ChatSensitivity, Speak2ChatTimeout, EqualizerPreset, + AutoPowerOffState, AutoPowerOffTime, PayloadTypeV1T2 as PTV1T2 +} from './sonyConfig.js'; + +/** +Sony module for Bluetooth battery meter service to provide, +battery information, ANC and Convesational awareness on device that support it. + +Reference and Credits: for V1 +https://codeberg.org/Freeyourgadget/Gadgetbridge + +https://github.com/aybruh00/SonyHeadphonesClient + +https://github.com/Plutoberth/SonyHeadphonesClient + +https://github.com/andreasolofsson/MDR-protocol +**/ +export const SonySocketV1 = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SonySocketV1', +}, class SonySocketV1 extends SonySocketBase { + _init(devicePath, profileManager, profile, modelData, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SonySocketV1-${identifier}`; + this._log = createLogger(tag); + this._supportedFunction = []; + this._callbacks = callbacks; + + this._battProps = { + battery1Level: 0, + battery1Status: '', + battery2Level: 0, + battery2Status: '', + battery3Level: 0, + battery3Status: '', + }; + + this._batteryDualSupported = modelData.batteryDual ?? false; + this._batteryDual2Supported = modelData.batteryDual2 ?? false; + this._batteryCaseSupported = modelData.batteryCase ?? false; + this._batterySingleSupported = modelData.batterySingle ?? false; + this._noNoiseCancellingSupported = modelData.noNoiseCancelling ?? false; + this._ambientSoundControlSupported = modelData.ambientSoundControl ?? false; + this._windNoiseReductionSupported = modelData.windNoiseReduction ?? false; + this._speakToChatEnabledSupported = modelData.speakToChatEnabled ?? false; + this._speakToChatConfigSupported = modelData.speakToChatConfig ?? false; + this._speakToChatFocusOnVoiceSupported = modelData.speakToChatFocusOnVoice ?? false; + this._pauseWhenTakenOffSupported = modelData.pauseWhenTakenOff ?? false; + this._equalizerSixBands = modelData.equalizerSixBands ?? false; + this._voiceNotifications = modelData.voiceNotifications ?? false; + this._audioUpsamplingSupported = modelData.audioUpsampling ?? false; + this._automaticPowerOffWhenTakenOff = modelData.automaticPowerOffWhenTakenOff ?? false; + this._automaticPowerOffByTime = modelData.automaticPowerOffByTime ?? false; + + this.startSocket(); + } + + _supports(funcType) { + return this._supportedFunction?.includes(funcType); + } + + _getProtocolInfo() { + this._log.info('GET ProtocolInfo'); + + const payload = [PTV1T1.CONNECT_GET_PROTOCOL_INFO, ValueType.FIXED]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetProtocolInfo'); + } + + _parseProtocolInfo(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE ProtocolInfo'); + + const protocolVersionBE = payload[1] << 8 | payload[2]; + const protocolMajor = (protocolVersionBE & 0xFF00) >> 8; + const protocolMinor = protocolVersionBE & 0x00FF; + + this._log.info(`Protocol Version: ${protocolMajor}.${protocolMinor}`); + } + + _getDeviceInfoFirmware() { + this._log.info('GET DeviceInfoFirmware'); + + const payload = [PTV1T1.CONNECT_GET_DEVICE_INFO, ValueType.FW_VERSION]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetDeviceInfoFirmware'); + } + + _parseDeviceInfoFirmware(payload) { + if (payload.length < 3) + return; + + const len = payload[2]; + + if (payload.length < 3 + len) + return; + + const fwBytes = payload.slice(3, 3 + len); + const fwVersion = new TextDecoder().decode(new Uint8Array(fwBytes)); + + this._log.info('Device Firmware Version:', fwVersion); + this._callbacks?.updateFirmwareVersion?.(fwVersion); + } + + _getSupportInfo() { + this._log.info('GET SupportInfo:'); + + const payload = [PTV1T1.CONNECT_GET_SUPPORT_FUNCTION, ValueType.FIXED]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetSupportInfo'); + } + + _parseSupportFunctionInfo(payload) { + this._log.info('PARSE SupportFunctionInfo'); + + if (payload.length < 3) + return; + + const numFunctions = payload[2]; + const requiredLen = 3 + numFunctions; + + if (payload.length < requiredLen) + return; + + const funcMap = Object.entries(FunctionTypeV1T1).reduce((acc, [name, value]) => { + acc[value] = name; + return acc; + }, {}); + + const supportedFunctionIds = []; + const availableFunctions = []; + + for (let i = 0; i < numFunctions; i++) { + const funcId = payload[3 + i]; + supportedFunctionIds.push(funcId); + + const funcName = funcMap[funcId] ?? + `Unknown_0x${funcId.toString(16).padStart(2, '0')}`; + availableFunctions.push(funcName); + } + + this._supportedFunction = supportedFunctionIds; + + if (availableFunctions.length > 0) + this._log.info(`Support Functions:\n${availableFunctions.join('\n')}`); + else + this._log.info('No supported functions found.'); + } + + + _getSingleBatteryRequest() { + this._log.info('GET BatteryRequest:'); + + const payload = [PTV1T1.COMMON_GET_BATTERY_LEVEL, BatteryType.SINGLE]; + const ackType = 'GetBatteryTypeSingle'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getDualBatteryRequest() { + this._log.info('GET BatteryRequest:'); + + const payload = [PTV1T1.COMMON_GET_BATTERY_LEVEL, BatteryType.DUAL]; + const ackType = 'GetBatteryTypeDual'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getCaseBatteryRequest() { + this._log.info('GET BatteryRequest:'); + + const payload = [PTV1T1.COMMON_GET_BATTERY_LEVEL, BatteryType.CASE]; + const ackType = 'GetBatteryTypeCase'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseBatteryStatus(payload) { + this._log.info(`PARSE BatteryStatus payload.length = ${payload.length}`); + + if (payload.length < 4) + return; + + const type = payload[1]; + if (!Object.values(BatteryType).includes(type)) + return; + + const hasCase = this._batteryCaseSupported; + + if (type === BatteryType.SINGLE || type === BatteryType.CASE) { + const level = Math.max(0, Math.min(payload[2], 100)); + const charging = payload[3] === 0x01; + const status = charging ? 'charging' : 'discharging'; + + if (hasCase) { + this._battProps.battery3Level = level; + this._battProps.battery3Status = status; + } else { + this._battProps.battery1Level = level; + this._battProps.battery1Status = status; + } + } else if (type === BatteryType.DUAL) { + if (payload[2] > 0) { + const level = Math.max(0, Math.min(payload[2], 100)); + const charging = payload[3] === 0x01; + const status = charging ? 'charging' : 'discharging'; + + this._battProps.battery1Level = level; + this._battProps.battery1Status = status; + } + + if (payload[4] > 0) { + const level = Math.max(0, Math.min(payload[4], 100)); + const charging = payload[5] === 0x01; + const status = charging ? 'charging' : 'discharging'; + + this._battProps.battery2Level = level; + this._battProps.battery2Status = status; + } + } else { + return; + } + + this._callbacks?.updateBatteryProps?.(this._battProps); + } + + + _getAmbientSoundControl() { + this._log.info('GET AmbientSoundControl:'); + + const payload = [PTV1T1.NC_ASM_GET_PARAM]; + payload.push(this._asmType); + const ackType = 'GetAmbientControl'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAmbientSoundControl(payload) { + if (payload.length !== 8) + return; + + this._log.info('PARSE AmbientSoundControl'); + + const m0 = payload[2], m1 = payload[3], m2 = payload[4]; + let mode = null; + + if (m0 === 0x00) { + mode = AmbientSoundMode.ANC_OFF; + } else if (m0 === 0x01) { + if (m1 === 0x00) { + mode = m2 === 0x00 ? AmbientSoundMode.AMBIENT : AmbientSoundMode.ANC_ON; + } else if (m1 === 0x02) { + if (m2 === 0x00) + mode = AmbientSoundMode.AMBIENT; + else if (m2 === 0x01) + mode = AmbientSoundMode.WIND; + else + mode = AmbientSoundMode.ANC_ON; + } + } + + if (!isValidByte(mode, AmbientSoundMode)) + return; + + const focusOnVoiceState = payload[6] === 0x01; + const level = payload[7]; + const ambientSoundLevel = level >= 0 && level <= 20 ? level : 10; + + this._log.info(`PARSE AmbientSoundControl: Mode: ${mode} Voice: ${focusOnVoiceState} ` + + `Level: ${level}`); + + this._callbacks?.updateAmbientSoundControl?.(mode, focusOnVoiceState, ambientSoundLevel); + } + + setAmbientSoundControl(mode, focusOnVoice, level) { + this._log.info( + `SET AmbientSoundControl: Mode: ${mode} Voice: ${focusOnVoice} ` + + `Level: ${level}`); + const payload = [PTV1T1.NC_ASM_SET_PARAM]; + + const modeIsOff = mode === AmbientSoundMode.ANC_OFF; + const modeIsNC = mode === AmbientSoundMode.ANC_ON; + const modeIsWNR = mode === AmbientSoundMode.WIND; + const modeIsAmbient = mode === AmbientSoundMode.AMBIENT; + + payload.push(0x02); + payload.push(modeIsOff ? 0x00 : 0x11); + payload.push(this._windNoiseReductionSupported ? 0x02 : 0x00); + + let modeCode = 0x00; + if (this._windNoiseReductionSupported) { + if (modeIsNC) + modeCode = 0x02; + else if (modeIsWNR) + modeCode = 0x01; + } else { + modeCode = modeIsNC ? 0x01 : 0x00; + } + + payload.push(modeCode); + payload.push(0x01); + payload.push(focusOnVoice ? 0x01 : 0x00); + + const ambientSoundLevel = modeIsOff || modeIsAmbient ? Math.max(1, level) : 0x00; + + payload.push(ambientSoundLevel); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAmbientSoundControl'); + } + + _getSpeakToChatEnabled() { + this._log.info('GET SpeakToChatEnabled'); + + const payload = [PTV1T1.SYSTEM_GET_PARAM]; + payload.push(0x05); + const ackType = 'GetSpeakToChatEnable'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseSpeakToChatEnable(payload) { + if (payload.length !== 4 || payload[2] !== 0x01) + return; + + this._log.info('PARSE SpeakToChatEnable'); + + const enabled = booleanFromByte(payload[3]); + if (enabled === null) + return; + + this._callbacks?.updateSpeakToChatEnable?.(enabled); + } + + setSpeakToChatEnabled(enabled) { + this._log.info(`SET SpeakToChatEnabled: ${enabled}`); + + const payload = [PTV1T1.SYSTEM_SET_PARAM]; + payload.push(0x05); + payload.push(0x01); + payload.push(enabled ? 0x01 : 0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetSpeakToChatEnabled'); + } + + _getSpeakToChatConfig() { + this._log.info('GET SpeakToChatConfig'); + + const payload = [PTV1T1.SYSTEM_GET_EXTENDED_PARAM]; + payload.push(0x05); + const ackType = 'GetSpeakToChatConfig'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseSpeakToChatConfig(payload) { + if (payload.length !== 6) + return; + + this._log.info('PARSE SpeakToChatConfig'); + + const sensCode = payload[3]; + if (!isValidByte(sensCode, Speak2ChatSensitivity)) + return; + + const timeoutCode = payload[5]; + if (!isValidByte(timeoutCode, Speak2ChatTimeout)) + return; + + this._callbacks?.updateSpeakToChatConfig?.(sensCode, timeoutCode); + } + + setSpeakToChatConfig(sensitivity, timeout) { + this._log.info(`SET SpeakToChatConfig: Sensitivity=${sensitivity}, Timeout=${timeout}`); + + if (!isValidByte(sensitivity, Speak2ChatSensitivity)) + return; + + if (!isValidByte(timeout, Speak2ChatTimeout)) + return; + + const payload = [PTV1T1.SYSTEM_SET_EXTENDED_PARAM]; + payload.push(0x05); + payload.push(0x00); + payload.push(sensitivity); + payload.push(0x00); + payload.push(timeout); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetSpeakToChatConfig'); + } + + _getEqualizer() { + this._log.info('GET Equalizer'); + + const payload = [PTV1T1.EQ_EBB_GET_PARAM]; + payload.push(0x01); + const ackType = 'GetEqualizer'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseEqualizer(payload) { + if (payload.length !== 10) + return; + + this._log.info('PARSE Equalizer'); + + const presetCode = payload[2]; + if (!isValidByte(presetCode, EqualizerPreset)) + return; + + const customBands = []; + if (payload[3] === 6) { + for (let i = 0; i < 6; i++) + customBands.push(payload[4 + i] - 10); + } else { + return; + } + + this._callbacks?.updateEqualizer?.(presetCode, customBands); + } + + setEqualizerPreset(presetCode) { + this._log.info(`SET EqualizerPreset: PresetCode=${presetCode}`); + + if (!isValidByte(presetCode, EqualizerPreset)) + return; + + const payload = [PTV1T1.EQ_EBB_SET_PARAM]; + payload.push(0x01); + payload.push(presetCode); + payload.push(0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetEqualizerPreset'); + } + + setEqualizer(presetCode, customBands) { + this._log.info( + `SET EqualizerCustomBands: Preset: ${presetCode} CustomBands=${customBands}`); + + const payload = [PTV1T1.EQ_EBB_SET_PARAM]; + payload.push(0x01); + payload.push(presetCode); + payload.push(0x06); + + if (customBands.length !== 6) + return; + + + for (let i = 0; i < 6; i++) + payload.push(customBands[i] + 10); + + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetEqualizer'); + } + + _getVoiceNotifications() { + this._log.info('GET VoiceNotifications'); + + const payload = [PTV1T1.VPT_GET_PARAM]; + payload.push(0x01); + payload.push(0x01); + const ackType = 'GetVoiceNotifications'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parseVoiceNotifications(payload) { + if (payload.length !== 4) + return; + + this._log.info('PARSE VoiceNotifications'); + + const enabled = booleanFromByte(payload[3]); + if (enabled === null) + return; + + this._callbacks?.updateVoiceNotifications?.(enabled); + } + + setVoiceNotifications(enabled) { + this._log.info(`SET VoiceNotifications: ${enabled}`); + + const payload = [PTV1T1.VPT_SET_PARAM]; + + payload.push(0x01); + payload.push(0x01); + payload.push(enabled ? 0x01 : 0x00); + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SetVoiceNotifications'); + } + + _getAudioUpsampling() { + this._log.info('GET AudioUpsampling'); + + const payload = [PTV1T1.AUDIO_GET_PARAM]; + payload.push(0x02); + const ackType = 'GetAudioUpsampling'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAudioUpsampling(payload) { + if (payload.length !== 4) + return; + + this._log.info('PARSE AudioUpsampling'); + + const enabled = booleanFromByte(payload[3]); + if (enabled === null) + return; + + this._callbacks?.updateAudioSampling?.(enabled); + } + + setAudioUpsampling(enabled) { + this._log.info(`SET AudioUpsampling: ${enabled}`); + + const payload = [PTV1T1.AUDIO_SET_PARAM]; + payload.push(0x02); + payload.push(0x00); + payload.push(enabled ? 0x01 : 0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAudioUpsampling'); + this._getUpscalingIndicator(); + } + + _getPauseWhenTakenOff() { + this._log.info('GET PauseWhenTakenOff'); + + const payload = [PTV1T1.SYSTEM_GET_PARAM]; + payload.push(0x03); + const ackType = 'GetPauseWhenTakenOff'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parsePauseWhenTakenOff(payload) { + if (payload.length !== 4) + return; + + this._log.info('PARSE PauseWhenTakenOff'); + + const enabled = booleanFromByte(payload[3]); + if (enabled === null) + return; + + this._callbacks?.updatePauseWhenTakenOff?.(enabled); + } + + setPauseWhenTakenOff(enabled) { + this._log.info(`SET PauseWhenTakenOff: ${enabled}`); + + const payload = [PTV1T1.SYSTEM_SET_PARAM]; + payload.push(0x03); + payload.push(0x00); + payload.push(enabled ? 0x01 : 0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetPauseWhenTakenOff'); + } + + _getAutomaticPowerOff() { + this._log.info('GET AutomaticPowerOff'); + + const payload = [PTV1T1.SYSTEM_GET_PARAM]; + payload.push(0x04); + const ackType = 'GetAutomaticPowerOff'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAutomaticPowerOff(payload) { + if (payload.length < 5) + return; + + this._log.info('PARSE AutomaticPowerOff'); + + const state = payload[3]; + const time = payload[4]; + + if (this._automaticPowerOffByTime) { + if (state !== AutoPowerOffState.DISABLE && !isValidByte(state, AutoPowerOffTime)) { + this._log.info(`Invalid Value for byte1 _parseAutomaticPowerOff: id=${state}`); + return; + } + + if (!isValidByte(time, AutoPowerOffTime)) { + this._log.info(`Invalid Value for byte2 _parseAutomaticPowerOff: id=${time}`); + return; + } + } else { + if (!isValidByte(state, AutoPowerOffState)) { + this._log.info(`Invalid Value for byte1 _parseAutomaticPowerOff: id=${state}`); + return; + } + if (time !== 0x00) { + this._log.info(`Invalid Value for byte2 _parseAutomaticPowerOff: id=${time}`); + return; + } + } + + const disabled = state === AutoPowerOffState.DISABLE; + this._currentAutoPowerTime = time; + this._callbacks?.updateAutomaticPowerOff?.(!disabled, time); + } + + setAutomaticPowerOff(enabled, time) { + this._log.info(`SET AutomaticPowerOff: enabled=${enabled} time: ${time}`); + + let state = AutoPowerOffState.DISABLE; + + if (this._automaticPowerOffByTime) { + if (!isValidByte(time, AutoPowerOffTime)) { + this._log.info(`Invalid Value for setAutomaticPowerOff: time: ${time}`); + return; + } + + if (enabled) + state = time; + else + time = 0x00; + } else { + time = 0x00; + if (enabled) + state = AutoPowerOffState.ENABLE; + } + + const payload = [PTV1T1.SYSTEM_SET_PARAM]; + payload.push(0x04); + payload.push(0x01); + payload.push(state); + payload.push(time); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAutomaticPowerOff'); + } + + _getCodecIndicator() { + this._log.info('GET CodecIndicator'); + + const payload = [PTV1T1.COMMON_GET_AUDIO_CODEC]; + payload.push(0x00); + const ackType = 'GetCodecIndicator'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseCodecIndicator(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE CodecIndicator'); + + if (payload[1] !== 0x00) + return; + + const codec = payload[2]; + if (!isValidByte(codec, AudioCodec)) + return; + + this._callbacks?.updateCodecIndicator?.(codec); + } + + _getUpscalingIndicator() { + this._log.info('GET UpscalingIndicator'); + + const payload = [PTV1T1.COMMON_GET_UPSCALING_EFFECT]; + payload.push(0x00); + const ackType = 'GetUpscalingIndicator'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseUpscalingIndicator(payload) { + if (payload.length < 4) + return; + + this._log.info('PARSE UpscalingIndicator'); + + const mode = payload[2]; + const show = payload[3] !== 0x00; + if (!isValidByte(mode, DseeType)) + return; + + this._callbacks?.updateUpscalingIndicator?.(mode, show); + } + + _getPairingMode() { + this._log.info('GET PairingMode'); + const payload = [PTV1T2.PERI_GET_STATUS, 0x01]; + const ackType = 'GetPairingMode'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parsePairingMode(payload) { + if (payload.length < 4) + return; + + this._log.info('PARSE PairingMode'); + + const enabled = booleanFromByte(payload[2]); + if (enabled === null) + return; + + const valid = booleanFromByte(payload[3]); + if (valid === null) + return; + + this._callbacks?.updatePairingMode?.(valid && enabled); + } + + setPairingMode(enabled) { + this._log.info(`SET PairingMode: ${enabled}`); + const payload = [PTV1T2.PERI_SET_STATUS, 0x01, enabled ? 0x01 : 0x00, 0x01]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SetPairingMode'); + } + + _getDeviceList() { + this._log.info('GET DeviceList'); + const payload = [PTV1T2.PERI_GET_PARAM, 0x01]; + const ackType = 'GetDeviceList'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parseDeviceList(payload) { + this._log.info(`payload.length : ${payload.length}`); + + if (payload.length < 4) + return; + + this._log.info('PARSE V1 DeviceList'); + + const deviceCount = payload[2]; + + let offset = 3; + let playbackMac = ''; + const devices = []; + + for (let i = 0; i < deviceCount; i++) { + if (payload.length < offset + 17) + return; + + const macBytes = payload.slice(offset, offset + 17); + const mac = new TextDecoder().decode(macBytes); + offset += 17; + + if (payload.length < offset + 1) + return; + + const connectedStatus = payload[offset++]; + + if (payload.length < offset + 1) + return; + + const nameLength = payload[offset++]; + + if (payload.length < offset + nameLength) + return; + + const nameBytes = payload.slice(offset, offset + nameLength); + const name = new TextDecoder().decode(nameBytes); + offset += nameLength; + const connected = connectedStatus !== 0; + + if (connectedStatus === payload[payload.length - 1]) + playbackMac = mac; + + devices.push({mac, name, connected}); + } + + if (payload.length !== offset + 1) + return; + + this._callbacks?.updateDeviceList?.(devices, playbackMac); + } + + _parseDeviceActionResult(payload) { + if (payload.length < 21) + return; + + this._log.info('PARSE DeviceActionResult'); + const action = payload[2]; + const result = payload[3]; + const macBytes = Uint8Array.from(payload.slice(4, 21)); + const mac = new TextDecoder().decode(macBytes); + this._callbacks?.updateDeviceActionStatus?.(action, result, mac); + } + + connectDevice(mac) { + this._log.info(`CONNECT Device: ${mac}`); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV1T2.PERI_SET_EX_PARAM, 0x00, 0x01, macBytes.length, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'ConnectDevice'); + } + + disconnectDevice(mac) { + this._log.info(`DISCONNECT Device: ${mac}`); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV1T2.PERI_SET_EX_PARAM, 0x00, 0x00, macBytes.length, ...macBytes]; + + this.addMessageQueue(MessageType.COMMAND_2, payload, 'DisconnectDevice'); + } + + unpairDevice(mac) { + this._log.info(`UNPAIR Device: ${mac}`); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV1T2.PERI_SET_EX_PARAM, 0x00, 0x02, macBytes.length, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'UnpairDevice'); + } + + + handleMessageType1(payload) { + switch (payload[0]) { + case PTV1T1.CONNECT_RET_PROTOCOL_INFO: + this.emit('ack-received', 'protocolInfo'); + this._parseProtocolInfo(payload); + break; + + case PTV1T1.CONNECT_RET_DEVICE_INFO: + if (payload[1] === 0x01) { + this.emit('ack-received', 'deviceInfoModel'); + this._parseDeviceInfoModel(payload); + } else if (payload[1] === 0x02) { + this.emit('ack-received', 'deviceInfoFirmware'); + this._parseDeviceInfoFirmware(payload); + } else if (payload[1] === 0x03) { + this.emit('ack-received', 'deviceInfoSeriesColor'); + this._parseDeviceInfoSeriesColor(payload); + } + break; + + case PTV1T1.CONNECT_RET_SUPPORT_FUNCTION: + this.emit('ack-received', 'supportInfo'); + this._parseSupportFunctionInfo(payload); + break; + + case PTV1T1.COMMON_RET_BATTERY_LEVEL: + case PTV1T1.COMMON_NTFY_BATTERY_LEVEL: + this.emit('ack-received', 'battery'); + this._parseBatteryStatus(payload); + break; + + case PTV1T1.NC_ASM_RET_PARAM: + case PTV1T1.NC_ASM_NTFY_PARAM: + this.emit('ack-received', 'ambientControl'); + this._parseAmbientSoundControl(payload); + break; + + case PTV1T1.SYSTEM_RET_PARAM: + case PTV1T1.SYSTEM_NTFY_PARAM: + if (payload[1] === 0x03) { + this.emit('ack-received', 'pauseWhenTakenOff'); + this._parsePauseWhenTakenOff(payload); + } else if (payload[1] === 0x04) { + this.emit('ack-received', 'automaticPowerOff'); + this._parseAutomaticPowerOff(payload); + } else if (payload[1] === 0x05) { + this.emit('ack-received', 'speakToChatEnable'); + this._parseSpeakToChatEnable(payload); + } + break; + + case PTV1T1.SYSTEM_RET_EXTENDED_PARAM: + case PTV1T1.SYSTEM_NTFY_EXTENDED_PARAM: + if (payload[1] === 0x05) { + this.emit('ack-received', 'speakToChatConfig'); + this._parseSpeakToChatConfig(payload); + } + break; + + case PTV1T1.EQ_EBB_RET_PARAM: + case PTV1T1.EQ_EBB_NTFY_PARAM: + this.emit('ack-received', 'equalizer'); + this._parseEqualizer(payload); + break; + + case PTV1T1.AUDIO_RET_PARAM: + case PTV1T1.AUDIO_NTFY_PARAM: + if (payload[1] === 0x01) { + this._parseAudioUpsampling(payload); + this.emit('ack-received', 'audioUpsampling'); + } + break; + + case PTV1T1.COMMON_RET_UPSCALING_EFFECT: + case PTV1T1.COMMON_NTFY_UPSCALING_EFFECT: + this._parseUpscalingIndicator(payload); + this.emit('ack-received', 'codecIndicator'); + break; + + case PTV1T1.COMMON_RET_AUDIO_CODEC: + case PTV1T1.COMMON_NTFY_AUDIO_CODEC: + this._parseCodecIndicator(payload); + this.emit('ack-received', 'upscalingIndicator'); + break; + } + } + + + handleMessageType2(payload) { + switch (payload[0]) { + case PTV1T1.VPT_RET_PARAM: + case PTV1T1.VPT_NTFY_PARAM: + this.emit('ack-received', 'voiceNotifications'); + this._parseVoiceNotifications(payload); + break; + + case PTV1T2.PERI_RET_STATUS: + case PTV1T2.PERI_NTFY_STATUS: + if (payload[1] === 0x01) { + this.emit('ack-received', 'pairingMode'); + this._parsePairingMode(payload); + } + break; + + case PTV1T2.PERI_RET_PARAM: + case PTV1T2.PERI_NTFY_PARAM: + if (payload[1] === 0x01) { + this.emit('ack-received', 'deviceList'); + this._parseDeviceList(payload); + } + break; + + case PTV1T2.PERI_NTFY_EX_PARAM: + if (payload[1] === 0x00) { + this.emit('ack-received', 'deviceList'); + this._parseDeviceActionResult(payload); + } + break; + } + } + + resendPendingRequest() { + if (!this.pendingRequestQueue?.length) + return; + + const pending = [...this.pendingRequestQueue]; + this.pendingRequestQueue.length = 0; + + for (const item of pending) { + if (item === 'GetBatteryTypeSingle') + this._getSingleBatteryRequest(); + else if (item === 'GetBatteryTypeDual') + this._getDualBatteryRequest(); + else if (item === 'GetBatteryTypeCase') + this._getCaseBatteryRequest(); + else if (item === 'GetAmbientControl') + this._getAmbientSoundControl(); + else if (item === 'GetSpeakToChatEnable') + this._getSpeakToChatEnabled(); + else if (item === 'GetSpeakToChatConfig') + this._getSpeakToChatConfig(); + else if (item === 'GetEqualizer') + this._getEqualizer(); + else if (item === 'GetVoiceNotifications') + this._getVoiceNotifications(); + else if (item === 'GetAudioUpsampling') + this._getAudioUpsampling(); + else if (item === 'GetPauseWhenTakenOff') + this._getPauseWhenTakenOff(); + else if (item === 'GetAutomaticPowerOff') + this._getAutomaticPowerOff(); + } + this.tagEndOfGetMessage(); + } + + _getCurrentState() { + this._log.info('GET CurrentState'); + + if (this._supportedFunction.length === 0) + return; + + const supportsCodecIndicator = this._supports(FunctionTypeV1T1.CODEC_INDICATOR); + const supportsUpscalingIndicator = this._supports(FunctionTypeV1T1.UPSCALING_INDICATOR); + this._callbacks?.updateCapabilities?.(supportsCodecIndicator, supportsUpscalingIndicator); + + if (this._supports(FunctionTypeV1T1.NOISE_CANCELLING_AND_AMBIENT_SOUND_MODE)) + this._asmType = 0x02; + else if (this._supports(FunctionTypeV1T1.AMBIENT_SOUND_MODE)) + this._asmType = 0x03; + else if (this._supports(FunctionTypeV1T1.NOISE_CANCELLING)) + this._asmType = 0x01; + else + this._asmType = 0x02; + + if (this._batterySingleSupported) + this._getSingleBatteryRequest(); + + if (this._batteryDualSupported) + this._getDualBatteryRequest(); + + if (this._batteryCaseSupported) + this._getCaseBatteryRequest(); + + if (supportsCodecIndicator) + this._getUpscalingIndicator(); + + if (supportsUpscalingIndicator) + this._getCodecIndicator(); + + if (this._voiceNotifications) + this._getVoiceNotifications(); + + if (this._equalizerSixBands) + this._getEqualizer(); + + if (this._ambientSoundControlSupported) + this._getAmbientSoundControl(); + + if (this._audioUpsamplingSupported) + this._getAudioUpsampling(); + + if (this._pauseWhenTakenOffSupported) + this._getPauseWhenTakenOff(); + + if (this._automaticPowerOffWhenTakenOffSupported) + this._getAutomaticPowerOff(); + + if (this._speakToChatEnabledSupported) + this._getSpeakToChatEnabled(); + + if (this._speakToChatConfigSupported) + this._getSpeakToChatConfig(); + + this.tagEndOfGetMessage(); + + this._supportedFunction = null; + } + + _requestDeviceInfoSupportFunctions() { + this._getDeviceInfoFirmware(); + + this.waitForResponse('supportInfo', () => this._getSupportInfo(), 5, 3) + .then(() => this._getCurrentState()) + .catch(err => this._log.error('supportInfo info initialization failed', err)); + } + + sendInit() { + this.waitForResponse('protocolInfo', () => this._getProtocolInfo(), 5, 3) + .then(() => this._requestDeviceInfoSupportFunctions()) + .catch(err => this._log.error('Protocol info initialization failed', err)); + } + + destroy() { + super.destroy(); + } +}); + diff --git a/bbm/lib/devices/sony/sonySocketV2.js b/bbm/lib/devices/sony/sonySocketV2.js new file mode 100644 index 0000000000..92a085db73 --- /dev/null +++ b/bbm/lib/devices/sony/sonySocketV2.js @@ -0,0 +1,1516 @@ +'use strict'; +import GObject from 'gi://GObject'; + +import {createLogger, getDeviceIdentifier} from '../logger.js'; +import {SonySocketBase} from './sonySocketBase.js'; +import {isValidByte, booleanFromByte} from '../deviceUtils.js'; + +import { + MessageType, PayloadTypeV2T1 as PTV2T1, FunctionTypeV2T1, PayloadTypeV2T2 as PTV2T2, + ValueType, BatteryType, AudioCodec, DseeType, BatteryStatus, AmbientSoundMode, + AutoAsmSensitivity, AsmType, Speak2ChatSensitivity, Speak2ChatTimeout, EqualizerPreset, + ListeningMode, BgmDistance, AutoPowerOffState, AutoPowerOffTime, AmbientButtonMode, + ButtonModes, EqInquiryType, FunctionTypeV2T2, ConnectionType +} from './sonyConfig.js'; + +/** +Sony module for Bluetooth battery meter service to provide, +battery information, ANC and Convesational awareness on device that support it. + +Reference and Credits: for V1 +https://codeberg.org/Freeyourgadget/Gadgetbridge + +https://github.com/mos9527/SonyHeadphonesClient + +https://github.com/andreasolofsson/MDR-protocol +**/ +export const SonySocketV2 = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SonySocketV2', +}, class SonySocketV2 extends SonySocketBase { + _init(devicePath, profileManager, profile, modelData, callbacks) { + super._init(devicePath, profileManager, profile); + const identifier = getDeviceIdentifier(devicePath); + const tag = `SonySocketV2-${identifier}`; + this._log = createLogger(tag); + this._log.info('SonySocketV2 init'); + this._supportedFunction = []; + this._callbacks = callbacks; + + this._asmType = null; + this._connectionType = null; + this._battProps = { + battery1Level: 0, + battery1Status: '', + battery2Level: 0, + battery2Status: '', + battery3Level: 0, + battery3Status: '', + }; + + this._batteryDualSupported = modelData.batteryDual ?? false; + this._batteryDual2Supported = modelData.batteryDual2 ?? false; + this._batteryCaseSupported = modelData.batteryCase ?? false; + this._batterySingleSupported = modelData.batterySingle ?? false; + this._noNoiseCancellingSupported = modelData.noNoiseCancelling ?? false; + this._ambientSoundControlSupported = modelData.ambientSoundControl ?? false; + this._ambientSoundControlNASupported = modelData.ambientSoundControlNA ?? false; + this._windNoiseReductionSupported = modelData.windNoiseReduction ?? false; + this._ambientSoundControlButtonMode = modelData.ambientSoundControlButtonMode ?? false; + this._speakToChatEnabledSupported = modelData.speakToChatEnabled ?? false; + this._speakToChatConfigSupported = modelData.speakToChatConfig ?? false; + this._speakToChatFocusOnVoiceSupported = modelData.speakToChatFocusOnVoice ?? false; + this._pauseWhenTakenOffSupported = modelData.pauseWhenTakenOff ?? false; + this._listeningModeSupported = modelData.listeningMode ?? false; + this._equalizerSixBands = modelData.equalizerSixBands ?? false; + this._equalizerTenBands = modelData.equalizerTenBands ?? false; + this._voiceNotifications = modelData.voiceNotifications ?? false; + this._voiceNotificationsVolume = modelData.voiceNotificationsVolume ?? false; + this._audioUpsamplingSupported = modelData.audioUpsampling ?? false; + this._automaticPowerOffWhenTakenOff = modelData.automaticPowerOffWhenTakenOff ?? false; + this._automaticPowerOffByTime = modelData.automaticPowerOffByTime ?? false; + this._buttonModesLeftRight = modelData.buttonModesLeftRight?.length > 0; + this._dualConnection = modelData.dualConnection ?? false; + this._hasRoutingIndicator = modelData.dualConnection?.hasRoutingIndicator ?? false; + this._hasRoutingControl = modelData.dualConnection?.hasRoutingControl ?? false; + this._hasActiveFix = modelData.dualConnection?.hasActiveFix ?? false; + + this._bgmProps = {active: false, distance: 0, mode: ListeningMode.STANDARD}; + + this.startSocket(); + } + + _supports(funcType) { + return this._supportedFunction?.includes(funcType); + } + + _supportsV2(funcType) { + return this._supportedFunctionV2?.includes(funcType); + } + + _getProtocolInfo() { + this._log.info('GET ProtocolInfo'); + + const payload = [PTV2T1.CONNECT_GET_PROTOCOL_INFO, ValueType.FIXED]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetProtocolInfo'); + } + + _parseProtocolInfo(payload) { + if (payload.length < 5) + return; + + this._log.info('PARSE ProtocolInfo'); + + const protocolVersionLE = + payload[1] | + payload[2] << 8 | + payload[3] << 16 | + payload[4] << 24; + + const protocolVersion = + protocolVersionLE >> 24 & 0xFF | + protocolVersionLE >> 8 & 0xFF00 | + protocolVersionLE << 8 & 0xFF0000 | + protocolVersionLE << 24 & 0xFF000000; + + const protocolMajor = protocolVersion >> 16 & 0xFFFF; + const protocolMinor = protocolVersion & 0xFFFF; + + this._log.info(`Protocol Version: ${protocolMajor}.${protocolMinor}`); + } + + _getDeviceInfoFirmware() { + this._log.info('GET DeviceInfoFirmware'); + + const payload = [PTV2T1.CONNECT_GET_DEVICE_INFO, ValueType.FW_VERSION]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetDeviceInfoFirmware'); + } + + _parseDeviceInfoFirmware(payload) { + if (payload.length < 3) + return; + + const len = payload[2]; + + if (payload.length < 3 + len) + return; + + const fwBytes = payload.slice(3, 3 + len); + const fwVersion = new TextDecoder().decode(new Uint8Array(fwBytes)); + + this._log.info('Device Firmware Version:', fwVersion); + this._callbacks?.updateFirmwareVersion?.(fwVersion); + } + + _getSupportInfo() { + this._log.info('GET SupportInfo:'); + + const payload = [PTV2T1.CONNECT_GET_SUPPORT_FUNCTION, ValueType.FIXED]; + this.addMessageQueue(MessageType.COMMAND_1, payload, 'GetSupportInfo'); + } + + _parseSupportFunctionInfo(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE SupportFunctionInfo'); + + const numFunctions = payload[2]; + const requiredLen = 3 + numFunctions * 2; + + if (payload.length < requiredLen) + return; + + + const funcMap = Object.entries(FunctionTypeV2T1).reduce((acc, [name, value]) => { + acc[value] = name; + return acc; + }, {}); + + const availableFunctions = []; + const supportedFunctionIds = []; + + for (let i = 0; i < numFunctions; i++) { + const funcId = payload[3 + i * 2]; + const priority = payload[3 + i * 2 + 1]; + const funcName = funcMap[funcId] ?? `Unknown_0x${funcId.toString(16).padStart(2, '0')}`; + availableFunctions.push(`${funcName} (priority=${priority})`); + supportedFunctionIds.push(funcId); + } + + this._supportedFunction = supportedFunctionIds; + + if (availableFunctions.length > 0) + this._log.info(`Support Functions:\n${availableFunctions.join('\n')}`); + else + this._log.info('No supported functions found.'); + } + + _getSupportInfoV2() { + this._log.info('GET SupportInfoV2:'); + + const payload = [PTV2T2.CONNECT_GET_SUPPORT_FUNCTION, ValueType.FIXED]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SupportInfoV2'); + } + + _parseSupportFunctionInfoV2(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE SupportFunctionInfoV2'); + + const numFunctions = payload[2]; + const requiredLen = 3 + numFunctions * 2; + + if (payload.length < requiredLen) + return; + + + const funcMap = Object.entries(FunctionTypeV2T2).reduce((acc, [name, value]) => { + acc[value] = name; + return acc; + }, {}); + + const availableFunctions = []; + const supportedFunctionIds = []; + + for (let i = 0; i < numFunctions; i++) { + const funcId = payload[3 + i * 2]; + const priority = payload[3 + i * 2 + 1]; + const funcName = funcMap[funcId] ?? `Unknown_0x${funcId.toString(16).padStart(2, '0')}`; + availableFunctions.push(`${funcName} (priority=${priority})`); + supportedFunctionIds.push(funcId); + } + + this._supportedFunctionV2 = supportedFunctionIds; + + if (availableFunctions.length > 0) + this._log.info(`Support Functions:\n${availableFunctions.join('\n')}`); + else + this._log.info('No supported functions found.'); + } + + _getSingleThdBatteryRequest() { + this._log.info('GET BatteryTypeSingleThd:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.SINGLE_THD]; + const ackType = 'GetBatteryTypeSingleThd'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getSingleBatteryRequest() { + this._log.info('GET GetBatteryTypeSingle:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.SINGLE]; + const ackType = 'GetBatteryTypeSingle'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getDualThdBatteryRequest() { + this._log.info('GET BatteryTypeDualThd:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.DUAL_THD]; + const ackType = 'GetBatteryTypeDualThd'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getDualBatteryRequest() { + this._log.info('GET BatteryTypeDual:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.DUAL]; + const ackType = 'GetBatteryTypeDual'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getCaseThdBatteryRequest() { + this._log.info('GET BatteryTypeCaseThd:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.CASE_THD]; + const ackType = 'GetBatteryTypeCaseThd'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _getCaseBatteryRequest() { + this._log.info('GET BatteryTypeCase:'); + + const payload = [PTV2T1.POWER_GET_STATUS, BatteryType.CASE]; + const ackType = 'GetBatteryTypeCase'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseBatteryStatus(payload) { + if (payload.length < 4) + return; + + this._log.info(`PARSE BatteryStatus payload.length = ${payload.length}`); + + const type = payload[1]; + if (!Object.values(BatteryType).includes(type)) + return; + + const getStatus = state => { + if (state === BatteryStatus.CHARGING || state === BatteryStatus.CHARGED) + return 'charging'; + else + return 'discharging'; + }; + + if (type === BatteryType.SINGLE || type === BatteryType.CASE || + type === BatteryType.SINGLE_THD || type === BatteryType.CASE_THD) { + const level = Math.max(0, Math.min(payload[2], 100)); + + if (type === BatteryType.CASE || type === BatteryType.CASE_THD) { + this._battProps.battery3Level = level; + this._battProps.battery3Status = getStatus(payload[3]); + } else { + this._battProps.battery1Level = level; + this._battProps.battery1Status = getStatus(payload[3]); + } + } else if (type === BatteryType.DUAL || type === BatteryType.DUAL_THD) { + if (payload[2] > 0) { + const level = Math.max(0, Math.min(payload[2], 100)); + this._battProps.battery1Level = level; + this._battProps.battery1Status = getStatus(payload[3]); + } + + if (payload[4] > 0) { + const level = Math.max(0, Math.min(payload[4], 100)); + this._battProps.battery2Level = level; + this._battProps.battery2Status = getStatus(payload[5]); + } + } else { + return; + } + + this._callbacks?.updateBatteryProps?.(this._battProps); + } + + + _getAmbientSoundControl() { + this._log.info('GET AmbientSoundControl:'); + + const payload = [PTV2T1.NCASM_GET_PARAM, this._asmType]; + const ackType = 'GetAmbientControl'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAmbientSoundControl(payload) { + if (payload.length < 6 || payload.length > 9) + return; + + this._log.info('PARSE AmbientSoundControl'); + + const idx = payload[1]; + + if (idx !== this._asmType) { + this._log.info('ERROR: Asm Type mismatch'); + return; + } + + const noNc = this._asmType === AsmType.ASM_SEAMLESS || this._asmType === AsmType.ASM_ON_OFF; + + const includesWind = + this._asmType === AsmType.MODE_NC_ASM_AUTO_NC_MODE_SWITCH_AND_ASM_SEAMLESS; + + const autoAmbientType = + this._asmType === AsmType.MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS_NA; + + let mode; + if (payload[3] === 0x00) { + mode = AmbientSoundMode.ANC_OFF; + } else { + if (includesWind) { + const sub = payload[5]; + if (sub === 0x03 || sub === 0x05) { + mode = AmbientSoundMode.WIND; + } else if (sub === 0x02) { + mode = payload[4] === 0x00 + ? AmbientSoundMode.ANC_ON + : AmbientSoundMode.AMBIENT; + } + } else if (noNc) { + mode = AmbientSoundMode.AMBIENT; + } else { + mode = payload[4] === 0x00 + ? AmbientSoundMode.ANC_ON + : AmbientSoundMode.AMBIENT; + } + if (mode === null) + return; + } + + let i = payload.length - (autoAmbientType ? 4 : 2); + const focusOnVoiceState = payload[i] === 0x01; + + i++; + const level = payload[i]; + const ambientSoundLevel = level >= 0 && level <= 20 ? level : 10; + let autoAmbient = false; + let sensitivity = AutoAsmSensitivity.STANDARD; + + if (autoAmbientType) { + i++; + const val = payload[i]; + if (val === 0x00 || val === 0x01) + autoAmbient = val === 0x01; + + i++; + const autoAmbientSensitivity = payload[i]; + if (isValidByte(autoAmbientSensitivity, AutoAsmSensitivity)) + sensitivity = autoAmbientSensitivity; + } + + this._callbacks?.updateAmbientSoundControl?.(mode, focusOnVoiceState, + ambientSoundLevel, autoAmbient, sensitivity); + } + + setAmbientSoundControl(mode, focusOnVoice, level, autoAmbient, sensitivity) { + this._log.info( + `SET AmbientSoundControl: Mode: ${mode} Voice: ${focusOnVoice} ` + + `Level: ${level} AutoAmbient: ${autoAmbient} Sensitivity: ${sensitivity}`); + + if (!this._asmType) { + this._log.info('ERROR: No supported NC/ASM mode found'); + return; + } + + const noNc = this._asmType === AsmType.ASM_SEAMLESS || this._asmType === AsmType.ASM_ON_OFF; + + const includesWind = + this._asmType === AsmType.MODE_NC_ASM_AUTO_NC_MODE_SWITCH_AND_ASM_SEAMLESS; + + const autoAmbientType = + this._asmType === AsmType.MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS_NA; + + const payload = [PTV2T1.NCASM_SET_PARAM]; + payload.push(this._asmType); + payload.push(0x01); + payload.push(mode === AmbientSoundMode.ANC_OFF ? 0x00 : 0x01); + + if (!noNc) + payload.push(mode === AmbientSoundMode.AMBIENT ? 0x01 : 0x00); + + if (includesWind) + payload.push(mode === AmbientSoundMode.WIND ? 0x03 : 0x02); + + payload.push(focusOnVoice ? 0x01 : 0x00); + payload.push(Math.max(1, level)); + + if (autoAmbientType) { + payload.push(autoAmbient ? 0x01 : 0x00); + payload.push(sensitivity); + } + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAmbientSoundControl'); + } + + _getSpeakToChatEnabled() { + this._log.info('GET SpeakToChatEnabled'); + + const payload = [PTV2T1.SYSTEM_GET_PARAM]; + payload.push(0x0C); + const ackType = 'GetSpeakToChatEnable'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseSpeakToChatEnable(payload) { + if (payload.length !== 4) + return; + + this._log.info('PARSE SpeakToChatEnable'); + + const disable = booleanFromByte(payload[2]); + if (disable === null) + return; + + this._callbacks?.updateSpeakToChatEnable?.(!disable); + } + + setSpeakToChatEnabled(enabled) { + this._log.info(`SET SpeakToChatEnabled: ${enabled}`); + + const payload = [PTV2T1.SYSTEM_SET_PARAM]; + payload.push(0x0C); + payload.push(enabled ? 0x00 : 0x01); + payload.push(0x01); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetSpeakToChatEnabled'); + } + + _getSpeakToChatConfig() { + this._log.info('GET SpeakToChatConfig'); + + const payload = [PTV2T1.SYSTEM_GET_EXT_PARAM]; + payload.push(0x0C); + const ackType = 'GetSpeakToChatConfig'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseSpeakToChatConfig(payload) { + if (payload.length < 4) + return; + + this._log.info('PARSE SpeakToChatConfig'); + + const sensCode = payload[2]; + if (!isValidByte(sensCode, Speak2ChatSensitivity)) + return; + + const timeoutCode = payload[3]; + if (!isValidByte(timeoutCode, Speak2ChatTimeout)) + return; + + this._speak2ChatSensitivity = sensCode; + this._speak2ChatTimeout = timeoutCode; + + this._callbacks?.updateSpeakToChatConfig?.( + this._speak2ChatSensitivity, + this._speak2ChatTimeout + ); + } + + setSpeakToChatConfig(sensitivity, timeout) { + this._log.info(`SET SpeakToChatConfig: Sensitivity=${sensitivity}, Timeout=${timeout}`); + + const payload = [PTV2T1.SYSTEM_SET_EXT_PARAM]; + payload.push(0x0C); + payload.push(sensitivity); + payload.push(timeout); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetSpeakToChatConfig'); + } + + _getListeningMode() { + this._log.info('GET ListeningMode:'); + + const payloadBgm = [PTV2T1.AUDIO_GET_PARAM, 0x03]; + this.pendingRequestQueue?.push('GetListeningModeBgm'); + this.addMessageQueue(MessageType.COMMAND_1, payloadBgm, 'GetListeningModeBgm'); + + const payloadNonBgm = [PTV2T1.AUDIO_GET_PARAM, 0x04]; + this.pendingRequestQueue?.push('GetListeningModeCinema'); + this.addMessageQueue(MessageType.COMMAND_1, payloadNonBgm, 'GetListeningModeCinema'); + } + + _parseBgmMode(payload) { + if (payload.length < 4) + return; + + this._log.info(`PARSE BgmMode: payload.length = ${payload.length}`); + + const disable = booleanFromByte(payload[2]); + if (disable === null) + return; + + const bgmDistanceMode = payload[3]; + if (!isValidByte(bgmDistanceMode, BgmDistance)) + return; + + this._callbacks?.updateBgmMode?.(!disable, bgmDistanceMode); + } + + _parseCinemaMode(payload) { + if (payload.length < 3) + return; + + this._log.info(`PARSE CinemaMode: payload.length = ${payload.length}`); + + const disable = booleanFromByte(payload[2]); + if (disable === null) + return; + + this._callbacks?.updateCinemaMode?.(!disable); + } + + setListeningMode(mode, distance) { + const payload = [PTV2T1.AUDIO_SET_PARAM]; + if (mode === ListeningMode.BGM || mode === ListeningMode.STANDARD) { + const bgmActive = mode === ListeningMode.BGM; + payload.push(0x03); + payload.push(bgmActive ? 0x00 : 0x01); + payload.push(distance); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetBgmMode'); + } else { + payload.push(0x04); + payload.push(0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetCinemaMode'); + } + } + + _getEqualizer() { + this._log.info('GET Equalizer'); + + const payload = [PTV2T1.EQEBB_GET_PARAM]; + payload.push(this._eqInquiryType); + const ackType = 'GetEqualizer'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseEqualizer(payload) { + if (this._equalizerTenBands && payload.length < 14 || + this._equalizerSixBands && payload.length < 10) + return; + + this._log.info('PARSE Equalizer'); + + const presetCode = payload[2]; + if (!isValidByte(presetCode, EqualizerPreset)) + return; + + const customBands = []; + if (this._equalizerTenBands && payload[3] === 0x0A) { + for (let i = 0; i < 10; i++) + customBands.push(payload[4 + i] - 6); + } else if (this._equalizerSixBands && payload[3] === 0x06) { + for (let i = 0; i < 6; i++) + customBands.push(payload[4 + i] - 10); + } else { + return; + } + + this._callbacks?.updateEqualizer?.(presetCode, customBands); + } + + setEqualizerPreset(presetCode) { + this._log.info(`SET EqualizerPreset: PresetCode=${presetCode}`); + + if (!isValidByte(presetCode, EqualizerPreset)) + return; + + const payload = [PTV2T1.EQEBB_SET_PARAM]; + payload.push(this._eqInquiryType); + payload.push(presetCode); + payload.push(0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetEqualizerPreset'); + } + + setEqualizer(presetCode, customBands) { + this._log.info( + `SET EqualizerCustomBands: Preset: ${presetCode} CustomBands=${customBands}`); + + const payload = [PTV2T1.EQEBB_SET_PARAM]; + payload.push(this._eqInquiryType); + payload.push(presetCode); + payload.push(this._equalizerTenBands ? 0x0A : 0x06); + + const bandCount = this._equalizerTenBands ? 10 : 6; + const levelCompensator = this._equalizerTenBands ? 6 : 10; + + if (customBands.length !== bandCount) { + this._log.info('setEqualizerCustomBands: invalid length'); + return; + } + + for (let i = 0; i < bandCount; i++) + payload.push(customBands[i] + levelCompensator); + + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetEqualizer'); + } + + _getAudioUpsampling() { + this._log.info('GET AudioUpsampling'); + + const payload = [PTV2T1.AUDIO_GET_PARAM]; + payload.push(0x01); + const ackType = 'GetAudioUpsampling'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAudioUpsampling(payload) { + if (payload.length !== 3) + return; + + this._log.info('PARSE AudioUpsampling'); + + const enabled = booleanFromByte(payload[2]); + if (enabled === null) + return; + + this._callbacks?.updateAudioSampling?.(enabled); + } + + setAudioUpsampling(enabled) { + this._log.info(`SET AudioUpsampling: ${enabled}`); + + const payload = [PTV2T1.AUDIO_SET_PARAM]; + payload.push(0x01); + payload.push(enabled ? 0x01 : 0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAudioUpsampling'); + this._getUpscalingIndicator(); + } + + _getButtonModesLeftRight() { + this._log.info('GET ButtonModesLeftRight'); + + const payload = [PTV2T1.SYSTEM_GET_PARAM]; + payload.push(0x03); + const ackType = 'GetButtonModesLeftRight'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseButtonModesLeftRight(payload) { + if (payload.length < 5) + return; + + this._log.info('PARSE ButtonModesLeftRight'); + + const leftMode = payload[3]; + const rightMode = payload[4]; + + if (payload[2] !== 0x02 || !isValidByte(leftMode, ButtonModes) || + !isValidByte(rightMode, ButtonModes)) + return; + + this._callbacks?.updateButtonModesLeftRight?.(leftMode, rightMode); + } + + setButtonModesLeftRight(leftMode, rightMode) { + this._log.info(`SET ButtonModesLeftRight: ${leftMode}, ${rightMode}`); + + const payload = [PTV2T1.SYSTEM_SET_PARAM]; + payload.push(0x03); + payload.push(0x02); + payload.push(leftMode); + payload.push(rightMode); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetButtonModesLeftRight'); + } + + _getAmbientSoundButton() { + this._log.info('GET AmbientSoundButton'); + + const payload = [PTV2T1.NCASM_GET_PARAM]; + payload.push(0x30); + const ackType = 'GetAmbientSoundButton'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAmbientSoundButton(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE AmbientSoundButton'); + + let buttonValue; + switch (payload[2]) { + case AmbientButtonMode.NC_ASM_OFF: + buttonValue = 0b111; + break; + case AmbientButtonMode.NC_ASM: + buttonValue = 0b011; + break; + case AmbientButtonMode.NC_OFF: + buttonValue = 0b101; + break; + case AmbientButtonMode.ASM_OFF: + buttonValue = 0b110; + break; + default: + return; + } + this._callbacks?.updateAmbientSoundButton?.(buttonValue); + } + + setAmbientSoundButton(value) { + let buttonMode; + switch (value) { + case 0b111: + buttonMode = AmbientButtonMode.NC_ASM_OFF; + break; + case 0b011: + buttonMode = AmbientButtonMode.NC_ASM; + break; + case 0b101: + buttonMode = AmbientButtonMode.NC_OFF; + break; + case 0b110: + buttonMode = AmbientButtonMode.ASM_OFF; + break; + default: + return; + } + + const payload = [PTV2T1.NCASM_SET_PARAM]; + payload.push(0x30); + payload.push(buttonMode); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAmbientSoundButton'); + } + + + _getVoiceNotifications() { + this._log.info('GET VoiceNotifications'); + + const payload = [PTV2T2.VOICE_GUIDANCE_GET_PARAM]; + payload.push(0x03); + const ackType = 'GetVoiceNotifications'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parseVoiceNotifications(payload) { + if (payload.length !== 4 && payload[1] !== 0x03) + return; + + this._log.info('PARSE VoiceNotifications'); + + const disable = booleanFromByte(payload[2]); + if (disable === null) + return; + + this._callbacks?.updateVoiceNotifications?.(!disable); + } + + setVoiceNotifications(enabled) { + this._log.info(`SET VoiceNotifications: ${enabled}`); + + const payload = [PTV2T2.VOICE_GUIDANCE_SET_PARAM]; + payload.push(0x03); + payload.push(enabled ? 0x00 : 0x01); + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SetVoiceNotifications'); + } + + _getVoiceNotificationsVolume() { + this._log.info('GET VoiceNotificationsVolume'); + + const payload = [PTV2T2.VOICE_GUIDANCE_GET_PARAM]; + payload.push(0x20); + const ackType = 'GetVoiceNotificationsVolume'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parseVoiceNotificationsVolume(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE VoiceNotificationsVolume'); + + const byte = payload[2]; + const vol = byte > 127 ? byte - 256 : byte; + if (vol < -2 || vol > 2) + return; + + this._callbacks?.updateVoiceNotificationsVolume?.(vol); + } + + setVoiceNotificationsVolume(vol) { + this._log.info(`SET VoiceNotificationsVolume: ${vol}`); + + const byte = vol < 0 ? 256 + vol : vol; + const payload = [PTV2T2.VOICE_GUIDANCE_SET_PARAM]; + payload.push(0x20); + payload.push(byte); + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SetVoiceNotificationsVolume'); + } + + _getPauseWhenTakenOff() { + this._log.info('GET PauseWhenTakenOff'); + + const payload = [PTV2T1.SYSTEM_GET_PARAM]; + payload.push(0x01); + const ackType = 'GetPauseWhenTakenOff'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parsePauseWhenTakenOff(payload) { + if (payload.length !== 3) + return; + + this._log.info('PARSE PauseWhenTakenOff'); + + const disabled = booleanFromByte(payload[2]); + if (disabled === null) + return; + + this._callbacks?.updatePauseWhenTakenOff?.(!disabled); + } + + setPauseWhenTakenOff(enabled) { + this._log.info(`SET PauseWhenTakenOff: ${enabled}`); + + const payload = [PTV2T1.SYSTEM_SET_PARAM]; + payload.push(0x01); + payload.push(enabled ? 0x00 : 0x01); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetPauseWhenTakenOff'); + } + + _getAutomaticPowerOff() { + this._log.info('GET AutomaticPowerOff'); + + const payload = [PTV2T1.POWER_GET_PARAM]; + payload.push(0x05); + const ackType = 'GetAutomaticPowerOff'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseAutomaticPowerOff(payload) { + if (payload.length < 4 || payload[1] !== 0x05) + return; + + this._log.info('PARSE AutomaticPowerOff'); + + const state = payload[2]; + const time = payload[3]; + + if (!isValidByte(state, AutoPowerOffState)) { + this._log.info(`Invalid Value for byte1 _parseAutomaticPowerOff: id=${state}`); + return; + } + + if (!isValidByte(time, AutoPowerOffTime)) { + this._log.info(`Invalid Value for byte1 _parseAutomaticPowerOff: id=${time}`); + return; + } + + const enabled = state === AutoPowerOffState.ENABLE; + this._currentAutoPowerTime = time; + this._callbacks?.updateAutomaticPowerOff?.(enabled, time); + } + + setAutomaticPowerOff(enabled, time) { + this._log.info(`SET AutomaticPowerOff: enabled=${enabled} time: ${time}`); + + const state = enabled ? AutoPowerOffState.ENABLE : AutoPowerOffState.DISABLE; + if (!isValidByte(time, AutoPowerOffTime)) { + this._log.info(`Invalid Value for setAutomaticPowerOff: time: ${time}`); + return; + } + + const payload = [PTV2T1.POWER_SET_PARAM]; + payload.push(0x05); + payload.push(state); + payload.push(this._automaticPowerOffByTime ? time : 0x00); + this.addMessageQueue(MessageType.COMMAND_1, payload, 'SetAutomaticPowerOff'); + } + + _getCodecIndicator() { + this._log.info('GET CodecIndicator'); + + const payload = [PTV2T1.COMMON_GET_STATUS]; + payload.push(0x02); + const ackType = 'GetCodecIndicator'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseCodecIndicator(payload) { + this._log.info('PARSE CodecIndicator'); + + const codec = payload[2]; + if (!isValidByte(codec, AudioCodec)) + return; + + this._callbacks?.updateCodecIndicator?.(codec); + } + + _getUpscalingIndicator() { + this._log.info('GET UpscalingIndicator'); + + const payload = [PTV2T1.COMMON_GET_STATUS]; + payload.push(0x03); + const ackType = 'GetUpscalingIndicator'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_1, payload, ackType); + } + + _parseUpscalingIndicator(payload) { + if (payload.length < 4) + return; + + this._log.info('PARSE UpscalingIndicator'); + + const mode = payload[2]; + const show = payload[3] !== 0x00; + if (!isValidByte(mode, DseeType)) + return; + + this._callbacks?.updateUpscalingIndicator?.(mode, show); + } + + _getPairingMode() { + this._log.info('GET PairingMode'); + const payload = [PTV2T2.PERI_GET_STATUS, this._connectionType]; + const ackType = 'GetPairingMode'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parsePairingMode(payload) { + if (payload.length < 4) + return; + + this._log.info('PARSE PairingMode'); + + const enabled = booleanFromByte(payload[2]); + if (enabled === null) + return; + + const valid = booleanFromByte(payload[3]); + if (valid === null) + return; + + this._callbacks?.updatePairingMode?.(valid && enabled); + } + + setPairingMode(enabled) { + this._log.info(`SET PairingMode: ${enabled}`); + const payload = [PTV2T2.PERI_SET_STATUS, this._connectionType, enabled ? 0x01 : 0x00, 0x01]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SetPairingMode'); + } + + _getDeviceList() { + this._log.info('GET DeviceList'); + const payload = [PTV2T2.PERI_GET_PARAM, this._connectionType]; + const ackType = 'GetDeviceList'; + this.pendingRequestQueue?.push(ackType); + this.addMessageQueue(MessageType.COMMAND_2, payload, ackType); + } + + _parseDeviceList(payload) { + this._log.info(`payload.length : ${payload.length}`); + + if (payload.length < 4) + return; + + this._log.info('PARSE DeviceList'); + + const deviceCount = payload[2]; + const playbackDevice = payload[payload.length - 1]; + + let offset = 3; + let playbackMac = ''; + const devices = []; + + for (let i = 0; i < deviceCount; i++) { + if (payload.length < offset + 22) + return; + + const macBytes = payload.slice(offset, offset + 17); + const mac = new TextDecoder().decode(macBytes); + offset += 17; + + const connectedStatus = payload[offset++]; + + const bluetoothClassOfDevice = + payload[offset] << 16 | + payload[offset + 1] << 8 | + payload[offset + 2]; + + offset += 3; + + const nameLength = payload[offset++]; + + if (payload.length < offset + nameLength) + return; + + const nameBytes = payload.slice(offset, offset + nameLength); + const name = new TextDecoder().decode(nameBytes); + offset += nameLength; + + const connected = connectedStatus > 0; + + if (connectedStatus === playbackDevice) + playbackMac = mac; + + devices.push({ + mac, + name, + connected, + bluetoothClassOfDevice, + }); + } + + if (payload.length !== offset + 1) + return; + + this._callbacks?.updateDeviceList?.(devices, playbackMac); + } + + _parseDeviceActionResult(payload) { + if (payload.length < 21) + return; + + this._log.info('PARSE DeviceActionResult'); + const action = payload[2]; + const result = payload[3]; + const macBytes = Uint8Array.from(payload.slice(4, 21)); + const mac = new TextDecoder().decode(macBytes); + this._callbacks?.updateDeviceActionStatus?.(action, result, mac); + } + + connectDevice(mac) { + this._log.info('CONNECT Device'); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV2T2.PERI_SET_EXTENDED_PARAM, this._connectionType, 0x01, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'ConnectDevice'); + } + + disconnectDevice(mac) { + this._log.info('DISCONNECT Device'); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV2T2.PERI_SET_EXTENDED_PARAM, this._connectionType, 0x00, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'DisconnectDevice'); + } + + unpairDevice(mac) { + this._log.info('UNPAIR Device'); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV2T2.PERI_SET_EXTENDED_PARAM, this._connectionType, 0x02, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'UnpairDevice'); + } + + _parseSourceSwitch(payload) { + if (payload.length < 19) + return; + + this._log.info('PARSE SourceSwitch'); + const macBytes = Uint8Array.from(payload.slice(2, 19)); + const mac = new TextDecoder().decode(macBytes); + this._callbacks?.updatePlaybackDevice?.(mac); + } + + setSourceSwitch(mac) { + this._log.info(`SET SourceSwitch: ${mac}`); + const macBytes = new TextEncoder().encode(mac); + const payload = [PTV2T2.PERI_SET_EXTENDED_PARAM, 0x01, ...macBytes]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SourceSwitch'); + } + + _parseSourceSwitchFixed(payload) { + if (payload.length < 3) + return; + + this._log.info('PARSE SourceSwitchFixed'); + const enabled = payload[2] === 0x00; + this._callbacks?.updateSourceSwitchFixed?.(enabled); + } + + setSourceSwitchFixed(enabled) { + this._log.info(`SET SourceSwitchFixed: ${enabled}`); + const payload = [PTV2T2.PERI_SET_PARAM, 0x01, enabled ? 0x00 : 0x01]; + this.addMessageQueue(MessageType.COMMAND_2, payload, 'SourceSwitchFixed'); + } + + handleMessageType1(payload) { + switch (payload[0]) { + case PTV2T1.CONNECT_RET_PROTOCOL_INFO: + this.emit('ack-received', 'protocolInfo'); + this._parseProtocolInfo(payload); + break; + + case PTV2T1.CONNECT_RET_DEVICE_INFO: + if (payload[1] === 0x01) { + this.emit('ack-received', 'deviceInfoModel'); + this._parseDeviceInfoModel(payload); + } else if (payload[1] === 0x02) { + this.emit('ack-received', 'deviceInfoFirmware'); + this._parseDeviceInfoFirmware(payload); + } else if (payload[1] === 0x03) { + this.emit('ack-received', 'deviceInfoSeriesColor'); + this._parseDeviceInfoSeriesColor(payload); + } + break; + + case PTV2T1.CONNECT_RET_SUPPORT_FUNCTION: + this.emit('ack-received', 'supportInfo'); + this._parseSupportFunctionInfo(payload); + break; + + case PTV2T1.POWER_RET_STATUS: + case PTV2T1.POWER_NTFY_STATUS: + this.emit('ack-received', 'battery'); + this._parseBatteryStatus(payload); + break; + + case PTV2T1.POWER_RET_PARAM: + case PTV2T1.POWER_NTFY_PARAM: + this.emit('ack-received', 'automaticPowerOff'); + this._parseAutomaticPowerOff(payload); + break; + + case PTV2T1.NCASM_RET_PARAM: + case PTV2T1.NCASM_NTFY_PARAM: + if (payload[1] === 0x30) { + this.emit('ack-received', 'ambientSoundButton'); + this._parseAmbientSoundButton(payload); + } else { + this.emit('ack-received', 'ambientControl'); + this._parseAmbientSoundControl(payload); + } + break; + + case PTV2T1.SYSTEM_RET_PARAM: + case PTV2T1.SYSTEM_NTFY_PARAM: + if (payload[1] === 0x01) { + this.emit('ack-received', 'pauseWhenTakenOff'); + this._parsePauseWhenTakenOff(payload); + } else if (payload[1] === 0x03) { + this.emit('ack-received', 'buttonModesLeftRight'); + this._parseButtonModesLeftRight(payload); + } else if (payload[1] === 0x0C) { + this.emit('ack-received', 'speakToChatEnable'); + this._parseSpeakToChatEnable(payload); + } + break; + + case PTV2T1.SYSTEM_RET_EXT_PARAM: + case PTV2T1.SYSTEM_NTFY_EXT_PARAM: + if (payload[1] === 0x0C) { + this.emit('ack-received', 'speakToChatConfig'); + this._parseSpeakToChatConfig(payload); + } + break; + + case PTV2T1.EQEBB_RET_PARAM: + case PTV2T1.EQEBB_NTFY_PARAM: + this.emit('ack-received', 'equalizer'); + this._parseEqualizer(payload); + break; + + case PTV2T1.AUDIO_RET_PARAM: + case PTV2T1.AUDIO_NTFY_PARAM: + if (payload[1] === 0x01) { + this._parseAudioUpsampling(payload); + this.emit('ack-received', 'audioUpsampling'); + } else if (payload[1] === 0x03) { + this.emit('ack-received', 'bgmMode'); + this._parseBgmMode(payload); + } else if (payload[1] === 0x04) { + this._parseCinemaMode(payload); + this.emit('ack-received', 'cinemaMode'); + } + break; + + case PTV2T1.COMMON_RET_STATUS: + case PTV2T1.COMMON_NTFY_STATUS: + if (payload[1] === 0x02) { + this._parseCodecIndicator(payload); + this.emit('ack-received', 'codecIndicator'); + } else if (payload[1] === 0x03) { + this._parseUpscalingIndicator(payload); + this.emit('ack-received', 'upsamplingIndicator'); + } + } + } + + handleMessageType2(payload) { + switch (payload[0]) { + case PTV2T2.CONNECT_RET_SUPPORT_FUNCTION: + this.emit('ack-received', 'supportInfoV2'); + this._parseSupportFunctionInfoV2(payload); + break; + + case PTV2T2.PERI_RET_STATUS: + case PTV2T2.PERI_NTFY_STATUS: + if (payload[1] === 0x00 || payload[1] === 0x02) { + this.emit('ack-received', 'pairingMode'); + this._parsePairingMode(payload); + } + break; + + case PTV2T2.PERI_RET_PARAM: + case PTV2T2.PERI_NTFY_PARAM: + if (payload[1] === 0x00 || payload[1] === 0x02) { + this.emit('ack-received', 'deviceList'); + this._parseDeviceList(payload); + } else if (payload[1] === 0x01) { + this.emit('ack-received', 'sourceSwitchFixed'); + this._parseSourceSwitchFixed(payload); + } + break; + + case PTV2T2.PERI_NTFY_EXTENDED_PARAM: + if (payload[1] === 0x00 || payload[1] === 0x02) { + this.emit('ack-received', 'deviceAction'); + this._parseDeviceActionResult(payload); + } else if (payload[1] === 0x01) { + this.emit('ack-received', 'sourceSwitch'); + this._parseSourceSwitch(payload); + } + break; + + case PTV2T2.VOICE_GUIDANCE_RET_PARAM: + case PTV2T2.VOICE_GUIDANCE_NTFY_PARAM: + if (payload[1] === 0x03) { + this.emit('ack-received', 'voiceNotifications'); + this._parseVoiceNotifications(payload); + } else if (payload[1] === 0x20) { + this.emit('ack-received', 'voiceNotificationsVolume'); + this._parseVoiceNotificationsVolume(payload); + } + break; + } + } + + resendPendingRequest() { + if (!this.pendingRequestQueue?.length) + return; + + const pending = [...this.pendingRequestQueue]; + this.pendingRequestQueue.length = 0; + + for (const item of pending) { + switch (item) { + case 'GetBatteryTypeSingleThd': + this._getSingleThdBatteryRequest(); + break; + case 'GetBatteryTypeSingle': + this._getSingleBatteryRequest(); + break; + case 'GetBatteryTypeDualThd': + this._getDualThdBatteryRequest(); + break; + case 'GetBatteryTypeDual': + this._getDualBatteryRequest(); + break; + case 'GetBatteryTypeCaseThd': + this._getCaseThdBatteryRequest(); + break; + case 'GetBatteryTypeCase': + this._getCaseBatteryRequest(); + break; + case 'GetAmbientControl': + this._getAmbientSoundControl(); + break; + case 'GetAmbientSoundButton': + this._getAmbientSoundButton(); + break; + case 'GetSpeakToChatEnable': + this._getSpeakToChatEnabled(); + break; + case 'GetSpeakToChatConfig': + this._getSpeakToChatConfig(); + break; + case 'GetListeningModeBgm': + case 'GetListeningModeCinema': + this._getListeningMode(); + break; + case 'GetEqualizer': + this._getEqualizer(); + break; + case 'GetAudioUpsampling': + this._getAudioUpsampling(); + break; + case 'GetUpscalingIndicator': + this._getUpscalingIndicator(); + break; + case 'GetCodecIndicator': + this._getCodecIndicator(); + break; + case 'GetButtonModesLeftRight': + this._getButtonModesLeftRight(); + break; + case 'GetVoiceNotifications': + this._getVoiceNotifications(); + break; + case 'GetVoiceNotificationsVolume': + this._getVoiceNotificationsVolume(); + break; + case 'GetPauseWhenTakenOff': + this._getPauseWhenTakenOff(); + break; + case 'GetAutomaticPowerOff': + this._getAutomaticPowerOff(); + break; + case 'GetPairingMode': + this._getPairingMode(); + break; + case 'GetDeviceList': + this._getDeviceList(); + break; + default: + this._log.info(`_resendPendingRequest: Unknown request type ${item}`); + break; + } + } + + this.tagEndOfGetMessage(); + } + + _getCurrentState() { + this._log.info('GET CurrentState'); + + if (this._supportedFunction.length === 0) + return; + + const supportsCodecIndicator = this._supports(FunctionTypeV2T1.CODEC_INDICATOR); + const supportsUpscalingIndicator = this._supports(FunctionTypeV2T1.UPSCALING_INDICATOR); + this._callbacks?.updateCapabilities?.(supportsCodecIndicator, supportsUpscalingIndicator); + + if (supportsCodecIndicator) + this._getCodecIndicator(); + + if (supportsUpscalingIndicator) + this._getUpscalingIndicator(); + + if (this._supports(FunctionTypeV2T1.BATTERY_LEVEL_WITH_THRESHOLD)) + this._getSingleThdBatteryRequest(BatteryType.SINGLE_THD); + else if (this._supports(FunctionTypeV2T1.BATTERY_LEVEL_INDICATOR)) + this._getSingleBatteryRequest(BatteryType.SINGLE); + + if (this._supports(FunctionTypeV2T1.LR_BATTERY_LEVEL_WITH_THRESHOLD)) + this._getDualThdBatteryRequest(BatteryType.DUAL_THD); + else if (this._supports(FunctionTypeV2T1.LEFT_RIGHT_BATTERY_LEVEL_INDICATOR)) + this._getDualBatteryRequest(BatteryType.DUAL); + + if (this._supports(FunctionTypeV2T1.CRADLE_BATTERY_LEVEL_WITH_THRESHOLD)) + this._getCaseThdBatteryRequest(BatteryType.CASE_THD); + else if (this._supports(FunctionTypeV2T1.CRADLE_BATTERY_LEVEL_INDICATOR)) + this._getCaseBatteryRequest(BatteryType.CASE); + + if (this._automaticPowerOffWhenTakenOff) + this._getAutomaticPowerOff(); + + if (this._supports(FunctionTypeV2T1.PRESET_EQ)) + this._eqInquiryType = EqInquiryType.PRESET_EQ; + else if (this._supports(FunctionTypeV2T1.EBB)) + this._eqInquiryType = EqInquiryType.EBB; + else if (this._supports(FunctionTypeV2T1.PRESET_EQ_NON_CUSTOMIZABLE)) + this._eqInquiryType = EqInquiryType.PRESET_EQ_NONCUSTOMIZABLE; + else if (this._supports(FunctionTypeV2T1.PRESET_EQ_AND_ERRORCODE)) + this._eqInquiryType = EqInquiryType.PRESET_EQ_AND_ERRORCODE; + else if (this._supports(FunctionTypeV2T1.CUSTOM_EQ)) + this._eqInquiryType = EqInquiryType.CUSTOM_EQ; + else + this._eqInquiryType = EqInquiryType.EBB; + + if (this._equalizerSixBands || this._equalizerTenBands) + this._getEqualizer(); + + if (this._audioUpsamplingSupported) + this._getAudioUpsampling(); + + /* eslint-disable max-len */ + if (this._supports(FunctionTypeV2T1.MODE_NC_ASM_NOISE_CANCELLING_DUAL_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT_NOISE_ADAPTATION)) + this._asmType = AsmType.MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS_NA; + else if (this._supports(FunctionTypeV2T1.MODE_NC_ASM_NOISE_CANCELLING_DUAL_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT)) + this._asmType = AsmType.MODE_NC_ASM_DUAL_NC_MODE_SWITCH_AND_ASM_SEAMLESS; + else if (this._supports(FunctionTypeV2T1.MODE_NC_ASM_NOISE_CANCELLING_DUAL_AUTO_AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT)) + this._asmType = AsmType.MODE_NC_ASM_AUTO_NC_MODE_SWITCH_AND_ASM_SEAMLESS; + else if (this._supports(FunctionTypeV2T1.AMBIENT_SOUND_MODE_LEVEL_ADJUSTMENT)) + this._asmType = AsmType.ASM_SEAMLESS; + else if (this._supports(FunctionTypeV2T1.AMBIENT_SOUND_MODE_ONOFF)) + this._asmType = AsmType.ASM_ON_OFF; + /* eslint-enable max-len */ + + if (this._asmType) + this._getAmbientSoundControl(); + + if (this._listeningModeSupported) + this._getListeningMode(); + + if (this._pauseWhenTakenOffSupported) + this._getPauseWhenTakenOff(); + + if (this._buttonModesLeftRight) + this._getButtonModesLeftRight(); + + if (this._speakToChatEnabledSupported) + this._getSpeakToChatEnabled(); + + if (this._speakToChatConfigSupported) + this._getAmbientSoundButton(); + + if (this._speakToChatConfigSupported) + this._getSpeakToChatConfig(); + + if (this._voiceNotifications) + this._getVoiceNotifications(); + + if (this._voiceNotificationsVolume) + this._getVoiceNotificationsVolume(); + + /* eslint-disable max-len */ + if (this._supports(FunctionTypeV2T2.PAIRING_DEVICE_MANAGEMENT_CLASSIC_BT)) + this._connectionType = ConnectionType.PAIRING_DEVICE_MANAGEMENT_CLASSIC_BT; + else if (this._supports(FunctionTypeV2T2.PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE_CLASSIC_BT)) + this._connectionType = ConnectionType.PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE; + else + this._connectionType = ConnectionType.PAIRING_DEVICE_MANAGEMENT_WITH_BLUETOOTH_CLASS_OF_DEVICE; + /* eslint-enable max-len */ + + if (this._dualConnection) { + this._getPairingMode(); + this._getDeviceList(); + } + + this.tagEndOfGetMessage(); + + this._supportedFunction = null; + } + + _requestDeviceInfoSupportFunctions() { + this._getDeviceInfoFirmware(); + + this.waitForResponse('supportInfo', () => this._getSupportInfo(), 5, 3) + .then(() => this._requestDeviceInfoSupportFunctionsV2()) + .catch(err => this._log.error('supportInfo info initialization failed', err)); + } + + _requestDeviceInfoSupportFunctionsV2() { + this.waitForResponse('supportInfoV2', () => this._getSupportInfoV2(), 5, 3) + .then(() => this._getCurrentState()) + .catch(err => this._log.error('supportInfoV2 info initialization failed', err)); + } + + sendInit() { + this.waitForResponse('protocolInfo', () => this._getProtocolInfo(), 5, 3) + .then(() => this._requestDeviceInfoSupportFunctions()) + .catch(err => this._log.error('Protocol info initialization failed', err)); + } + + destroy() { + super.destroy(); + } +}); + diff --git a/bbm/lib/enhancedDeviceSupportManager.js b/bbm/lib/enhancedDeviceSupportManager.js new file mode 100644 index 0000000000..c1321d20af --- /dev/null +++ b/bbm/lib/enhancedDeviceSupportManager.js @@ -0,0 +1,363 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {getBluezDeviceProxy} from './bluezDeviceProxy.js'; +import {createLogger, sanitizeDevPath} from './devices/logger.js'; + +import {ProfileManager} from './devices/profileManager.js'; +import {AirpodsDevice, isAirpods, DeviceTypeAirpods} from './devices/airpods/airpodsDevice.js'; +import { + SonyDevice, isSonyV1, isSonyV2, DeviceTypeSonyV1, DeviceTypeSonyV2 +} from './devices/sony/sonyDevice.js'; +import { + GalaxyBudsDevice, isGalaxyLegacy, isGalaxyBuds, DeviceTypeGalaxyLegacy, DeviceTypeGalaxyBuds +} from './devices/galaxyBuds/galaxyBudsDevice.js'; +import { + NothingBudsDevice, isNothingBuds, DeviceTypeNothingBuds +} from './devices/nothingBuds/nothingBudsDevice.js'; +import { + GoogleBudsDevice, isGoogleBuds, DeviceTypeGoogleBuds +} from './devices/googleBuds/googleBudsDevice.js'; +import { + RedmiBudsDevice, isRedmiBuds, DeviceTypeRedmiBuds +} from './devices/redmiBuds/redmiBudsDevice.js'; +import { + SenhBudsDevice, isSenhBuds, DeviceTypeSenhBuds +} from './devices/senhBuds/senhBudsDevice.js'; +import { + BoseBudsDevice, isBoseBuds, DeviceTypeBoseBuds +} from './devices/boseBuds/boseBudsDevice.js'; +import {GfpsDevice, isGfps, DeviceTypeGfps} from './devices/gfps/gfpsDevice.js'; +import {GattBasDevices, isGattBas, DeviceTypeGattBas} from './devices/gattBas/gattBasDevices.js'; + +export const EnhancedDeviceSupportManager = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_EnhancedDeviceSupportManager', +}, class EnhancedDeviceSupportManager extends GObject.Object { + _init(toggle) { + super._init(); + this._toggle = toggle; + this._settings = toggle.settings; + this._extPath = toggle.extPath; + this._deviceMap = new Map(); + this._log = createLogger('EnhancedDeviceSupportManager'); + this._profileManager = new ProfileManager(this._notifyCb.bind(this)); + } + + _notifyCb(type) { + this._toggle.notifyCb?.(type); + } + + updateDeviceMapCb(path, dataHandler) { + if (this._deviceMap.has(path)) { + const deviceProps = this._deviceMap.get(path); + deviceProps.dataHandler = dataHandler; + this._deviceMap.set(path, deviceProps); + this._toggle.sync(); + } + } + + onDeviceSync(path, connected, icon, alias) { + let deviceProps = this._deviceMap.get(path); + if (!deviceProps) { + deviceProps = { + type: null, connected, dataHandler: null, deviceIcon: icon, + enhancedDevice: null, pendingDetection: true, bluezId: null, + bluezProxy: null, alias, + }; + this._deviceMap.set(path, deviceProps); + } else { + deviceProps = this._deviceMap.get(path); + if (deviceProps.connected && !connected) + this._destroyEnhancedDevice(path); + + deviceProps.connected = connected; + } + + if (deviceProps.pendingDetection) { + const bluezDeviceProxy = getBluezDeviceProxy(path); + const uuids = bluezDeviceProxy.UUIDs ?? []; + + if (uuids.length === 0) { + this._log.info(`Detection pending path=${sanitizeDevPath(path)} waitingFor=UUIDs`); + deviceProps.pendingDetection = true; + this._waitForBluezProps(path, bluezDeviceProxy, ['UUIDs'], deviceProps); + return { + type: deviceProps.type, dataHandler: deviceProps.dataHandler, + pendingDetection: deviceProps.pendingDetection, + }; + } + + /* ----- Add device variant here _______ */ + const deviceModes = [ + { + enabled: this._toggle.airpodsEnabled, + check: isAirpods, + type: DeviceTypeAirpods, + }, + { + enabled: this._toggle.sonyEnabled, + check: isSonyV1, + type: DeviceTypeSonyV1, + }, + { + enabled: this._toggle.sonyEnabled, + check: isSonyV2, + type: DeviceTypeSonyV2, + }, + { + enabled: this._toggle.galaxyBudsEnabled, + check: isGalaxyLegacy, + type: DeviceTypeGalaxyLegacy, + }, + { + enabled: this._toggle.galaxyBudsEnabled, + check: isGalaxyBuds, + type: DeviceTypeGalaxyBuds, + }, + { + enabled: this._toggle.nothingBudsEnabled, + check: isNothingBuds, + type: DeviceTypeNothingBuds, + }, + { + enabled: this._toggle.googleBudsEnabled, + check: isGoogleBuds, + type: DeviceTypeGoogleBuds, + }, + { + enabled: this._toggle.redmiBudsEnabled, + check: isRedmiBuds, + type: DeviceTypeRedmiBuds, + }, + { + enabled: this._toggle.senhBudsEnabled, + check: isSenhBuds, + type: DeviceTypeSenhBuds, + }, + { + enabled: this._toggle.boseBudsEnabled, + check: isBoseBuds, + type: DeviceTypeBoseBuds, + }, + { + enabled: this._toggle.gfpsEnabled, + check: isGfps, + type: DeviceTypeGfps, + }, + { + enabled: this._toggle.gattBasEnabled, + check: isGattBas, + type: DeviceTypeGattBas, + }, + ]; + /* ------------------------------------- */ + for (const mode of deviceModes) { + if (!mode.enabled) + continue; + + const {supported, bluezProps} = mode.check(bluezDeviceProxy, uuids); + + if (supported === 'pending') { + this._log.info( + `Detection pending path=${sanitizeDevPath(path)} type=${mode.type} ` + + `waitingFor=${bluezProps.join(',')}` + ); + deviceProps.pendingDetection = true; + this._waitForBluezProps(path, bluezDeviceProxy, bluezProps, deviceProps); + break; + } + + if (supported === 'yes') { + deviceProps.type = mode.type; + deviceProps.pendingDetection = false; + break; + } + deviceProps.pendingDetection = false; + } + } + return { + type: deviceProps.type, dataHandler: deviceProps.dataHandler, + pendingDetection: deviceProps.pendingDetection, + }; + } + + _waitForBluezProps(path, bluezDeviceProxy, bluezProps, deviceProps) { + if (deviceProps.bluezId && deviceProps.bluezDeviceProxy) + return; + + const allPropsReady = () => { + return bluezProps.every(prop => { + const value = bluezDeviceProxy[prop]; + if (Array.isArray(value)) + return value.length > 0; + + return value !== null && value !== undefined; + }); + }; + + const onPropsChanged = (_iface, changed, _invalidated) => { + if (!bluezProps.some(prop => prop in changed)) + return; + + if (allPropsReady()) { + this._log.info( + `Pending detection properties ready path=${sanitizeDevPath(path)} ` + + `props=${bluezProps.join(',')}` + ); + if (this._deviceMap.has(path)) { + const props = this._deviceMap.get(path); + props.bluezDeviceProxy.disconnect(deviceProps.bluezId); + props.bluezId = null; + props.bluezDeviceProxy = null; + } else { + deviceProps.bluezDeviceProxy.disconnect(deviceProps.bluezId); + deviceProps.bluezId = null; + deviceProps.bluezDeviceProxy = null; + } + this._toggle.sync(); + } + }; + + deviceProps.bluezId = bluezDeviceProxy.connect('g-properties-changed', onPropsChanged); + deviceProps.bluezDeviceProxy = bluezDeviceProxy; + } + + + updateEnhancedDevicesInstance() { + for (const [path, deviceProps] of this._deviceMap.entries()) { + if (deviceProps.type && deviceProps.connected && !deviceProps.enhancedDevice) { + this._log.info( + `Creating enhanced device path=${sanitizeDevPath(path)} ` + + `type=${deviceProps.type}` + ); + /* ----- Add device variant here _______ */ + if (deviceProps.type === DeviceTypeAirpods) { + deviceProps.enhancedDevice = + new AirpodsDevice(this._settings, path, deviceProps.alias, this._extPath, + this._profileManager, this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeSonyV1 || + deviceProps.type === DeviceTypeSonyV2) { + deviceProps.enhancedDevice = + new SonyDevice(this._settings, path, deviceProps.alias, this._extPath, + this._profileManager, this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeGalaxyBuds || + deviceProps.type === DeviceTypeGalaxyLegacy) { + deviceProps.enhancedDevice = + new GalaxyBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeNothingBuds) { + deviceProps.enhancedDevice = + new NothingBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeGoogleBuds) { + deviceProps.enhancedDevice = + new GoogleBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeRedmiBuds) { + deviceProps.enhancedDevice = + new RedmiBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeSenhBuds) { + deviceProps.enhancedDevice = + new SenhBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeBoseBuds) { + deviceProps.enhancedDevice = + new BoseBudsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeGfps) { + deviceProps.enhancedDevice = + new GfpsDevice(this._settings, path, deviceProps.alias, + this._extPath, this._profileManager, + this.updateDeviceMapCb.bind(this)); + } else if (deviceProps.type === DeviceTypeGattBas) { + deviceProps.enhancedDevice = + new GattBasDevices(this._settings, path, deviceProps.deviceIcon, + this.updateDeviceMapCb.bind(this)); + } + /* ------------------------------------- */ + } else if (!deviceProps.connected && deviceProps.enhancedDevice) { + this._destroyEnhancedDevice(path); + } + } + } + + _ensureFileCopied(sourcePath, targetPath) { + const source = Gio.File.new_for_path(sourcePath); + const target = Gio.File.new_for_path(targetPath); + + try { + if (target.query_exists(null)) { + const fileType = + target.query_file_type(Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS, null); + if (fileType === Gio.FileType.SYMBOLIC_LINK) { + // Removing previously installed symlink + target.delete(null); + } else { + return; + } + } + + source.copy(target, Gio.FileCopyFlags.NONE, null, null); + } catch { + // Do nothing + } + } + + _createDesktopIconFiles() { + const homeDir = GLib.get_home_dir(); + const appDir = `${homeDir}/.local/share/applications`; + const iconDir = `${homeDir}/.local/share/icons`; + const sourceDesktopFile = `${this._extPath}/script/moreSettings.desktop`; + const targetDesktopFile = + `${appDir}/com.github.maniacx.Bluetooth-Battery-Meter-More-Settings.desktop`; + + const sourceIconFile = `${this._extPath}/icons/hicolor/scalable/actions/bbm-logo.svg`; + const targetIconFile = `${iconDir}/bluetooth-battery-meter-maniacx-github-logo.svg`; + GLib.mkdir_with_parents(appDir, 0o755); + GLib.mkdir_with_parents(iconDir, 0o755); + this._ensureFileCopied(sourceDesktopFile, targetDesktopFile); + this._ensureFileCopied(sourceIconFile, targetIconFile); + } + + _destroyEnhancedDevice(path) { + if (!this._deviceMap.has(path)) + return; + + const deviceProps = this._deviceMap.get(path); + + if (deviceProps.bluezId && deviceProps.bluezDeviceProxy) { + deviceProps.bluezDeviceProxy.disconnect(deviceProps.bluezId); + deviceProps.bluezDeviceProxy = null; + deviceProps.bluezId = null; + } + + deviceProps.dataHandler = null; + deviceProps.enhancedDevice?.destroy(); + deviceProps.enhancedDevice = null; + } + + _removedEnhancedDevice(path) { + if (!this._deviceMap.has(path)) + return; + + this._destroyEnhancedDevice(path); + this._deviceMap.delete(path); + } + + destroy() { + const paths = Array.from(this._deviceMap.keys()); + for (const path of paths) + this._removedEnhancedDevice(path); + + this._profileManager = null; + } +}); diff --git a/bbm/lib/panelButtonMultiDevice.js b/bbm/lib/panelButtonMultiDevice.js new file mode 100644 index 0000000000..9d5880f488 --- /dev/null +++ b/bbm/lib/panelButtonMultiDevice.js @@ -0,0 +1,54 @@ +'use strict'; +import GObject from 'gi://GObject'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {PanelPopupMenu} from './widgets/panelPopupMenu.js'; + +export const PanelButtonMultiDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_PanelButtonMultiDevice', +}, class PanelButtonMultiDevice extends PanelMenu.Button { + constructor(settings, gIcon, widgetInfo) { + super(0.5, _('Bluetooth Battery Meter')); + this._settings = settings; + this._gIcon = gIcon; + this._widgetInfo = widgetInfo; + this._deviceMap = new Map(); + this._lastSelectedPath = ''; + this.visible = false; + this._battInfoMenu = new PopupMenu.PopupMenuItem(_('Bluetooth Battery Meter'), + {reactive: false, can_focus: false, style_class: 'bbm-panel-title-label'}); + this._battInfoMenu.label.x_expand = true; + this.menu.addMenuItem(this._battInfoMenu); + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + + this.connectObject('destroy', () => this._deviceMap.clear(), this); + } + + addDevice(path, alias, dataHandler) { + let item = null; + if (this._deviceMap.has(path)) + return item; + + item = new PanelPopupMenu(this._settings, this._gIcon, path, alias, + this._widgetInfo, dataHandler, this.menu, false); + this.menu.addMenuItem(item); + this._deviceMap.set(path, {item, dataHandler}); + this.visible = this._deviceMap.size > 0; + return item; + } + + removeDevice(path) { + if (this._deviceMap.has(path)) { + const deviceProps = this._deviceMap.get(path); + deviceProps.item.destroy(); + deviceProps.item = null; + this._deviceMap.delete(path); + } + this.visible = this._deviceMap.size > 0; + } +}); + + diff --git a/bbm/lib/panelButtonSingleDevice.js b/bbm/lib/panelButtonSingleDevice.js new file mode 100644 index 0000000000..bed61d76be --- /dev/null +++ b/bbm/lib/panelButtonSingleDevice.js @@ -0,0 +1,155 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {PanelPopupMenu} from './widgets/panelPopupMenu.js'; + +export const PanelButtonSingleDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_PanelButtonSingleDevice', +}, class PanelButtonSingleDevice extends PanelMenu.Button { + constructor(settings, gIcon, widgetInfo) { + super(0.5, _('Bluetooth Battery Meter')); + this._settings = settings; + this._gIcon = gIcon; + this._widgetInfo = widgetInfo; + this._deviceMap = new Map(); + this._lastSelectedPath = ''; + this.visible = false; + this._indicatorBox = new St.BoxLayout(); + this._defaultPath = settings.get_string('default-selected-path'); + + this.add_child(this._indicatorBox); + this._battInfoMenu = new PopupMenu.PopupMenuItem(_('Bluetooth Battery Meter'), + {reactive: false, can_focus: false, style_class: 'bbm-panel-title-label'}); + this._battInfoMenu.label.x_expand = true; + this.menu.addMenuItem(this._battInfoMenu); + this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem()); + + this._settings.connectObject( + 'changed::default-selected-path', () => { + this._defaultPath = this._settings.get_string('default-selected-path'); + this._updateGUI(this._defaultPath); + }, + this + ); + this._setupWidgets(); + + this.connectObject('destroy', () => this._deviceMap.clear(), this); + } + + _setupWidgets() { + this._leftLabel = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'power-status', + }); + this._mulitpleIcon = new St.Icon({ + style_class: 'system-status-icon', + }); + this._rightLabel = new St.Label({ + text: '...', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'power-status', + }); + this._indicatorBox.add_child(this._leftLabel); + this._indicatorBox.add_child(this._mulitpleIcon); + this._indicatorBox.add_child(this._rightLabel); + } + + addDevice(path, alias, dataHandler) { + let item = null; + if (this._deviceMap.has(path)) + return item; + + item = new PanelPopupMenu(this._settings, this._gIcon, path, alias, + this._widgetInfo, dataHandler, this.menu, true); + this.menu.addMenuItem(item); + this._deviceMap.set(path, {item, dataHandler}); + this._updateGUI(path); + this.visible = this._deviceMap.size > 0; + return item; + } + + removeDevice(path) { + if (this._deviceMap.has(path)) { + const deviceProps = this._deviceMap.get(path); + if (this._dataHandler === deviceProps.dataHandler) + this._dataHandler.disconnectObject(this); + + deviceProps.item.destroy(); + deviceProps.item = null; + this._deviceMap.delete(path); + } + + if (this._deviceMap.size > 0) { + const randomPath = this._deviceMap.keys().next().value; + this._updateGUI(randomPath); + this.visible = true; + } else { + this.visible = false; + } + } + + _updateWidgets(config, props) { + const battery1Level = props.battery1Level === 0 ? '...' : `${props.battery1Level}%`; + const battery2Level = props.battery2Level === 0 ? '...' : `${props.battery2Level}%`; + + let showLeft = false; + + if (config.battery1Icon && config.battery2Icon) { + showLeft = config.panelButtonLabelFixed || props.battery2Level > 0; + if (showLeft) + this._leftLabel.text = battery1Level; + else + this._rightLabel.text = battery1Level; + } else if (config.battery1Icon && !config.battery2Icon) { + this._rightLabel.text = battery1Level; + } + + this._leftLabel.visible = showLeft; + if (showLeft) + this._rightLabel.text = battery2Level; + + this._mulitpleIcon.gicon = this._gIcon(`bbm-${config.commonIcon}-symbolic.svg`); + } + + _updateGUI(path) { + if (this._deviceMap.has(this._defaultPath)) + this._lastSelectedPath = this._defaultPath; + else + this._lastSelectedPath = ''; + + if (this._lastSelectedPath === '' && this._deviceMap.has(path)) + this._lastSelectedPath = path; + + if (this._deviceMap.has(this._lastSelectedPath)) { + const dataHandler = this._deviceMap.get(this._lastSelectedPath).dataHandler; + if (this._dataHandler !== dataHandler) { + this._dataHandler?.disconnectObject(this); + this._dataHandler = dataHandler; + const config = this._dataHandler.getConfig(); + const props = this._dataHandler.getProps(); + this._updateWidgets(config, props); + for (const {item} of this._deviceMap.values()) + item?.updatePinButton(); + + this._dataHandler.connectObject( + 'configuration-changed', () => { + this._updateWidgets( + this._dataHandler.getConfig(), this._dataHandler.getProps()); + }, + 'properties-changed', () => { + this._updateWidgets( + this._dataHandler.getConfig(), this._dataHandler.getProps()); + }, + this + ); + } + } + } +}); + diff --git a/bbm/lib/upower/upowerDevice.js b/bbm/lib/upower/upowerDevice.js new file mode 100644 index 0000000000..30b343fc3c --- /dev/null +++ b/bbm/lib/upower/upowerDevice.js @@ -0,0 +1,296 @@ +'use strict'; +import GObject from 'gi://GObject'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; +import UPower from 'gi://UPowerGlib'; + +import {WidgetManagerUPower} from './widgetManagerUPower.js'; +import * as Helper from './upowerHelper.js'; + +const deviceKindMapping = { + [UPower.DeviceKind.MOUSE]: 'input-mouse', + [UPower.DeviceKind.KEYBOARD]: 'input-keyboard', + [UPower.DeviceKind.TOUCHPAD]: 'touchpad', + [UPower.DeviceKind.GAMING_INPUT]: 'input-gaming', + [UPower.DeviceKind.PEN]: 'input-tablet', + [UPower.DeviceKind.MONITOR]: 'video-display', + [UPower.DeviceKind.PDA]: 'pda', + [UPower.DeviceKind.PHONE]: 'phone', + [UPower.DeviceKind.MEDIA_PLAYER]: 'multimedia-player', + [UPower.DeviceKind.COMPUTER]: 'computer', + [UPower.DeviceKind.PEN]: 'input-tablet', + [UPower.DeviceKind.MODEM]: 'modem', + [UPower.DeviceKind.NETWORK]: 'network-wireless', + [UPower.DeviceKind.HEADSET]: 'audio-headset', + [UPower.DeviceKind.SPEAKERS]: 'audio-speakers', + [UPower.DeviceKind.HEADPHONES]: 'audio-headphones', + [UPower.DeviceKind.VIDEO]: 'camera-video', + [UPower.DeviceKind.OTHER_AUDIO]: 'audio-card', + [UPower.DeviceKind.PRINTER]: 'printer', + [UPower.DeviceKind.SCANNER]: 'scanner', + [UPower.DeviceKind.CAMERA]: 'camera-photo', + [UPower.DeviceKind.WEARABLE]: 'wearable', +}; + +const UpowerDevice = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_UpowerDevice', + Properties: { + 'batteryPercentage': GObject.ParamSpec.int( + 'batteryPercentage', '', 'Battery Percentage', + GObject.ParamFlags.READWRITE, 0, 100, 0 + ), + }, +}, class UpowerDevice extends GObject.Object { + _init(upowerClientObj, path) { + super._init(); + this._toggle = upowerClientObj._toggle; + this._upowerClientObj = upowerClientObj; + this._settings = upowerClientObj._settings; + this._widgetInfo = upowerClientObj._widgetInfo; + this._path = path; + this._initializeUPowerDevice(); + } + + async _initializeUPowerDevice() { + this._deviceCancellable = new Gio.Cancellable(); + [this._proxy, this._proxyId] = + await Helper.initDeviceProxy( + this._path, this._deviceCancellable, _ => this._sync()); + + if (!this._proxy || !this._proxyId || !this._deviceCancellable || + this._deviceCancellable.is_cancelled()) + return; + this._sync(); + } + + _sync() { + const isPresent = this._proxy.get_cached_property('IsPresent').unpack(); + const model = this._proxy.get_cached_property('Model').unpack(); + const percentage = this._proxy.get_cached_property('Percentage').unpack(); + const state = this._proxy.get_cached_property('State').unpack(); + const isValidBatteryState = state !== UPower.DeviceState.UNKNOWN; + + let alias, icon; + let deviceProp = {}; + let devicePropUpdated = false; + if (this._upowerClientObj._deviceList.has(this._path)) { + deviceProp = this._upowerClientObj._deviceList.get(this._path); + icon = deviceProp.icon; + alias = deviceProp.alias; + + if (deviceProp.model !== model) { + deviceProp.model = model; + devicePropUpdated = true; + } + if (isValidBatteryState && !deviceProp.reportsState) { + deviceProp.reportsState = true; + devicePropUpdated = true; + } + } else { + const type = this._proxy.get_cached_property('Type').unpack(); + icon = deviceKindMapping[type] || 'upower-devices'; + alias = model; + deviceProp = { + alias, + icon, + model, + hideDevice: false, + reportsState: isValidBatteryState, + }; + this._upowerClientObj._deviceList.set(this._path, deviceProp); + this._upowerClientObj._delayedUpdateDeviceGsettings(); + } + let device; + if (this._upowerClientObj._deviceWidgets.has(this._path)) + device = this._upowerClientObj._deviceWidgets.get(this._path); + + const showDevice = + !deviceProp.hideDevice && isPresent && percentage > 0 && + (!deviceProp.reportsState || isValidBatteryState); + + + if (showDevice && !device) { + device = new WidgetManagerUPower(this._toggle, this._path, alias, + icon, percentage); + + this._upowerClientObj._deviceWidgets.set(this._path, device); + } else if (showDevice && device) { + device.updatePercentage(percentage); + } else if (!showDevice && device) { + device?.destroy(); + device = null; + this._upowerClientObj._deviceWidgets.delete(this._path); + } + + if (devicePropUpdated) { + this._upowerClientObj._deviceList.set(this._path, deviceProp); + this._upowerClientObj._pushDevicesToGsetting(); + } + } + + destroy() { + if (this._proxyId && this._proxy) + this._proxy.disconnect(this._proxyId); + if (this._deviceCancellable) { + this._deviceCancellable.cancel(); + this._deviceCancellable = null; + } + this._proxyId = null; + this._proxy = null; + } +}); + +export const UpowerClient = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_UpowerClient', +}, class UpowerClient extends GObject.Object { + constructor(toggle) { + super(); + this._toggle = toggle; + this._settings = toggle.settings; + this._widgetInfo = toggle.widgetInfo; + this._requestedProps = ['PowerSupply', 'NativePath']; + this._deviceItems = new Map(); + this._deviceWidgets = new Map(); + this._deviceList = new Map(); + this._pullDevicesFromGsetting(); + this._connectSettingsSignal(true); + this._initializeUpower(); + } + + async _initializeUpower() { + this._cancellable = new Gio.Cancellable(); + this._dbusProxy = await Helper.initProxy(this._cancellable); + if (!this._dbusProxy) + return; + + const devices = + await Helper.getDevices(this._dbusProxy, this._cancellable, this._requestedProps); + if (!this._cancellable || this._cancellable.is_cancelled()) + return; + this._dbusSignalId = Helper.watchDevices(this._dbusProxy, this._refreshDevices.bind(this)); + for (const dev of devices) + this._createDevice(dev); + } + + _createDevice(dev) { + const path = dev.path; + const isPowerSupply = dev.properties['PowerSupply']; + const nativePath = dev.properties['NativePath']; + if (!isPowerSupply && !nativePath.startsWith('/org/bluez/')) { + if (this._deviceItems.has(path)) { + this._deviceItems.get(path)._sync(); + } else { + const device = new UpowerDevice(this, path); + this._deviceItems.set(path, device); + } + } + } + + _removeDevice(path) { + if (this._deviceWidgets.has(path)) { + this._deviceWidgets.get(path)?.destroy(); + this._deviceWidgets.delete(path); + } + if (this._deviceItems.has(path)) { + this._deviceItems.get(path)?.destroy(); + this._deviceItems.delete(path); + } + } + + async _refreshDevices(path, action) { + if (action === 'add') { + const device = await Helper.getDeviceProps(path, null, this._requestedProps); + this._createDevice(device); + } else if (action === 'remove') { + this._removeDevice(path); + } + } + + _connectSettingsSignal(connect) { + if (connect) { + this._settingSignalId = this._settings.connect('changed::upower-device-list', () => { + this._pullDevicesFromGsetting(); + this._deviceWidgets.forEach(device => device?.destroy()); + this._deviceWidgets.clear(); + this._deviceItems.forEach(device => device?._sync()); + }); + } else if (this._settingSignalId) { + this._settings.disconnect(this._settingSignalId); + this._settingSignalId = null; + } + } + + _pullDevicesFromGsetting() { + this._deviceList.clear(); + const deviceList = this._settings.get_strv('upower-device-list'); + if (deviceList.length !== 0) { + for (const jsonString of deviceList) { + const item = JSON.parse(jsonString); + const path = item.path; + const deviceProps = { + 'alias': item['alias'], + 'icon': item['icon'], + 'model': item['model'], + 'hideDevice': item['hide-device'], + 'reportsState': item['reports-state'], + }; + if (!deviceProps.alias) + deviceProps.alias = deviceProps.model; + this._deviceList.set(path, deviceProps); + } + } + } + + _pushDevicesToGsetting() { + const deviceList = []; + for (const [path, deviceProps] of this._deviceList) { + const item = { + path, + 'alias': deviceProps.alias, + 'icon': deviceProps.icon, + 'model': deviceProps.model, + 'hide-device': deviceProps.hideDevice, + 'reports-state': deviceProps.reportsState, + }; + deviceList.push(JSON.stringify(item)); + } + this._connectSettingsSignal(false); + this._settings.set_strv('upower-device-list', deviceList); + this._connectSettingsSignal(true); + } + + _delayedUpdateDeviceGsettings() { + if (this._delayedTimerId) + GLib.source_remove(this._delayedTimerId); + this._delayedTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + this._pushDevicesToGsetting(); + this._delayedTimerId = null; + return GLib.SOURCE_REMOVE; + }); + } + + destroy() { + if (this._delayedTimerId) + GLib.source_remove(this._delayedTimerId); + this._delayedTimerId = null; + if (this._cancellable) { + this._cancellable.cancel(); + this._cancellable = null; + } + if (this._dbusSignalId && this._dbusProxy) + this._dbusProxy.disconnect(this._dbusSignalId); + this._dbusSignalId = null; + this._dbusProxy = null; + this._connectSettingsSignal(false); + if (this._deviceWidgets) { + this._deviceWidgets.forEach(device => device?.destroy()); + this._deviceWidgets.clear(); + } + this._deviceWidgets = null; + if (this._deviceItems) { + this._deviceItems.forEach(item => item?.destroy()); + this._deviceItems.clear(); + } + this._deviceItems = null; + } +}); diff --git a/bbm/lib/upower/upowerHelper.js b/bbm/lib/upower/upowerHelper.js new file mode 100644 index 0000000000..4b9b387c1b --- /dev/null +++ b/bbm/lib/upower/upowerHelper.js @@ -0,0 +1,92 @@ +'use strict'; +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +const BUS = 'org.freedesktop.UPower'; +const U_PATH = '/org/freedesktop/UPower'; +const U_IFACE = 'org.freedesktop.UPower'; +const U_PROPS = 'org.freedesktop.DBus.Properties'; +const UD_IFACE = 'org.freedesktop.UPower.Device'; + +export async function initProxy(cancellable) { + try { + return await Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null, BUS, U_PATH, U_IFACE, cancellable); + } catch { + console.log('Bluetooth-Battery-Meter: Failed to create DBus proxy'); + return null; + } +} + +export async function initDeviceProxy(path, cancellable, onPropertiesChangedCallback) { + try { + const proxy = await Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, Gio.DBusProxyFlags.NONE, null, BUS, path, UD_IFACE, cancellable); + const proxyId = proxy.connect('g-properties-changed', () => { + onPropertiesChangedCallback(proxy); + }); + return [proxy, proxyId]; + } catch { + console.log(`Bluetooth-Battery-Meter: Failed to create DBus proxy for ${path}`); + return [null, null]; + } +} + +export async function getDeviceProps(path, cancellable, requestedProps) { + try { + const response = await Gio.DBus.system.call( + BUS, path, U_PROPS, 'GetAll', + new GLib.Variant('(s)', [UD_IFACE]), GLib.VariantType.new('(a{sv})'), + Gio.DBusCallFlags.NONE, -1, cancellable + ); + const [deviceProps] = response.deep_unpack(); + const filteredProps = {}; + requestedProps.forEach(prop => { + if (deviceProps[prop] !== undefined) + filteredProps[prop] = deviceProps[prop]?.unpack(); + }); + return {path, properties: filteredProps}; + } catch { + console.log(`Bluetooth-Battery-Meter: Could not get properties for ${path}`); + return null; + } +} + +export async function getDevices(dbusProxy, cancellable, requestedProps) { + try { + const response = + await dbusProxy.call('EnumerateDevices', null, Gio.DBusCallFlags.NONE, -1, cancellable); + if (cancellable.is_cancelled()) + return []; + + if (response?.deep_unpack) { + const devices = response.deep_unpack()[0]; + if (Array.isArray(devices) && devices.length > 0) { + const deviceList = + await Promise.all(devices.map( + path => getDeviceProps(path, cancellable, requestedProps))); + return deviceList.filter(device => device !== null); + } + } + } catch { + console.log('Bluetooth-Battery-Meter: Could not EnumerateDevices'); + } + return []; +} + +export function watchDevices(dbusProxy, createDevicesCallback) { + const dbusConnectId = + dbusProxy.connect('g-signal', (proxy, senderName, signalName, parameters) => { + if (signalName === 'DeviceAdded') { + const path = parameters.deep_unpack()[0]; + createDevicesCallback(path, 'add'); + } else if (signalName === 'DeviceRemoved') { + const path = parameters.deep_unpack()[0]; + createDevicesCallback(path, 'remove'); + } + }); + return dbusConnectId; +} + + + diff --git a/bbm/lib/upower/widgetManagerUPower.js b/bbm/lib/upower/widgetManagerUPower.js new file mode 100644 index 0000000000..6056cd42b9 --- /dev/null +++ b/bbm/lib/upower/widgetManagerUPower.js @@ -0,0 +1,84 @@ +'use strict'; +import GObject from 'gi://GObject'; + +import {BluetoothIndicator} from '../widgets/bluetoothIndicator.js'; +import {OnHoverMenu} from '../widgets/onHoverMenu.js'; +import {createConfig, createProperties, DataHandler} from '../dataHandler.js'; + +export const WidgetManagerUPower = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_WidgetManagerUPower', + Properties: { + 'batteryPercentage': GObject.ParamSpec.int('batteryPercentage', '', 'Battery Percentage', + GObject.ParamFlags.READWRITE, 0, 100, 0), + }, +}, class WidgetManagerUPower extends GObject.Object { + _init(toggle, path, alias, deviceIcon, percentage) { + super._init(); + this.toggle = toggle; + this.settings = toggle.settings; + this.path = path; + this.deviceIcon = deviceIcon; + this.alias = alias; + this.gIcon = this.toggle.gIcon; + this.widgetInfo = toggle.widgetInfo; + this.isUnlockSession = this.widgetInfo.isUnlockSession; + this.hoverModeEnabled = toggle.hoverModeEnabled; + this.batteryPercentage = percentage; + this.indicatorMode = 2; + this._dconfig = createConfig(); + this._dprops = createProperties(); + this._dconfig.battery1Icon = this.deviceIcon; + this._dconfig.commonIcon = this.deviceIcon; + this._dconfig.albumArtIcon = this.deviceIcon; + this._dprops.computedBatteryLevel = this.batteryPercentage; + this._dprops.battery1Level = this.batteryPercentage; + this._dataHandler = new DataHandler(this._dconfig, this._dprops); + this._updateUI(); + } + + updatePercentage(percentage) { + this.batteryPercentage = percentage; + } + + _updateUI() { + if (this.toggle.panelButton && !this.isUnlockSession && !this._popupMenuWidgetItem) { + this._popupMenuWidgetItem = + this.toggle.panelButton.addDevice(this.path, this.alias, this._dataHandler); + } + + this._startIndicator(); + } + + _startIndicator() { + if (this.indicator || !this.deviceIcon) + return; + this.indicator = new BluetoothIndicator(this); + this.toggle.addIndicatorWidget(this.indicator); + + if (this.hoverModeEnabled && !this.isUnlockSession && !this._onHoverMenu) { + this._onHoverMenu = new OnHoverMenu(this.indicator, this.settings, this.gIcon, + this.path, this.alias, this.widgetInfo, this._dataHandler); + } + } + + _destroyIndicator() { + this._onHoverMenu?.destroy(); + this._onHoverMenu = null; + this.indicator?.destroy(); + this.indicator = null; + } + + _destroyOnDisconnect() { + this._dataHandler = null; + this.toggle?.panelButton?.removeDevice(this.path); + this._popupMenuWidgetItem = null; + this._destroyIndicator(); + } + + destroy() { + this._destroyOnDisconnect(); + this.settings = null; + this.toggle = null; + this._device = null; + } +}); diff --git a/bbm/lib/widgetManagerBluez.js b/bbm/lib/widgetManagerBluez.js new file mode 100644 index 0000000000..d54e3ba1c9 --- /dev/null +++ b/bbm/lib/widgetManagerBluez.js @@ -0,0 +1,256 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {BluetoothIndicator} from './widgets/bluetoothIndicator.js'; +import {BluetoothGnomePopupMenuItem} from './widgets/bluetoothGnomePopupMenu.js'; +import {BluetoothPopupMenuItem} from './widgets/bluetoothPopupMenu.js'; +import {BluetoothPopupSubMenuItem} from './widgets/bluetoothPopupSubMenu.js'; +import {OnHoverMenu} from './widgets/onHoverMenu.js'; +import {createConfig, createProperties, DataHandler} from './dataHandler.js'; + +export const WidgetManagerBluez = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_WidgetManagerBluez', + Properties: { + 'batteryPercentage': GObject.ParamSpec.int('batteryPercentage', '', 'Battery Percentage', + GObject.ParamFlags.READWRITE, 0, 100, 0), + }, +}, class WidgetManagerBluez extends GObject.Object { + _init(toggle, device, batteryReported, qsLevelEnabled, indicatorMode, deviceIcon) { + super._init(); + this.toggle = toggle; + this.settings = toggle.settings; + this.path = device.get_object_path(); + this.device = device; + this.deviceIcon = deviceIcon; + this.alias = device.alias; + this.gIcon = toggle.gIcon; + this.widgetInfo = toggle.widgetInfo; + this.isUnlockSession = this.widgetInfo.isUnlockSession; + this.hoverModeEnabled = toggle.hoverModeEnabled; + this.batteryReported = batteryReported; + this.qsLevelEnabled = qsLevelEnabled; + this.indicatorMode = indicatorMode; + this._client = toggle._bluetoothToggle._client; + this._config = createConfig(); + this._props = createProperties(); + + this._checkLateBluezBatteryReporting(); + + if (!this.toggle.modifyQuickSettings) + this.popupMenuItem = new BluetoothGnomePopupMenuItem(this); + else if (this.toggle.usePopupInQuickSettings) + this.popupMenuItem = new BluetoothPopupSubMenuItem(this); + else + this.popupMenuItem = new BluetoothPopupMenuItem(this); + + + + + this.device.connectObject( + 'notify::alias', () => this._aliasUpdated(this.alias), + 'notify::connected', () => { + if (!this.device.connected) { + this._destroyOnDisconnect(); + this.batteryPercentage = 0; + } + this._recordTimeEvent(); + }, + this + ); + } + + _checkLateBluezBatteryReporting() { + this.device.bind_property('battery_percentage', + this, 'batteryPercentage', + GObject.BindingFlags.SYNC_CREATE); + this.connectObject( + 'notify::batteryPercentage', () => { + if (!this.batteryReported && this.device.battery_percentage > 0) { + const props = this.toggle.deviceList.get(this.path); + this.toggle.deviceList.set(this.path, { + ...props, batteryReported: true, + qsLevelEnabled: true, indicatorMode: 2, + }); + this.batteryReported = true; + this.toggle.pushDevicesToGsetting(); + } + this._updateDataHandler(); + }, + this + ); + this._updateDataHandler(); + } + + _updateDataHandler() { + if (!this._dataHandler && this.device.battery_percentage > 0) { + this._config.battery1Icon = this.deviceIcon; + this._config.commonIcon = this.deviceIcon; + this._config.albumArtIcon = this.deviceIcon; + this._props.computedBatteryLevel = this.batteryPercentage; + this._props.battery1Level = this.batteryPercentage; + this._dataHandler = new DataHandler(this._config, this._props); + this.popupMenuItem?.setDataHandler?.(this._dataHandler); + this._updateUI(); + } else if (this.device.battery_percentage > 0) { + this._props.computedBatteryLevel = this.batteryPercentage; + this._props.battery1Level = this.batteryPercentage; + this._dataHandler?.setProps(this._props); + } + this._updateUI(); + } + + update() { + if (this.toggle.deviceList.has(this.path)) { + let qsPropsUpdated = false; + let indicatorPropsUpdated = false; + const props = this.toggle.deviceList.get(this.path); + if (this.qsLevelEnabled !== props.qsLevelEnabled) { + this.qsLevelEnabled = props.qsLevelEnabled; + qsPropsUpdated = true; + } + if (this.deviceIcon !== props.icon) { + this.deviceIcon = props.icon; + this._config.battery1Icon = this.deviceIcon; + this._config.commonIcon = this.deviceIcon; + this._config.albumArtIcon = this.deviceIcon; + this._dataHandler?.setConfig(this._config); + qsPropsUpdated = true; + indicatorPropsUpdated = true; + } + if (qsPropsUpdated) + this.popupMenuItem?.updateProperties(this.qsLevelEnabled, this.deviceIcon); + + if (this.indicatorMode !== props.indicatorMode) { + this.indicatorMode = props.indicatorMode; + indicatorPropsUpdated = true; + } + if (indicatorPropsUpdated) + this.indicator?.updateProperties(this.indicatorMode, this.deviceIcon); + + if (this.indicatorMode === 0) + this._destroyIndicator(); + else + this._updateDataHandler(); + } + } + + _aliasUpdated(alias) { + if (this._onHoverMenu) + this._onHoverMenu.updateAlias(alias); + if (this.toggle.panelButton) + this._popupMenuWidgetItem?.updateAlias(alias); + this.popupMenuItem?.updateAlias?.(alias); + } + + _updateUI() { + if (!this.device.connected) + return; + + if (this._dataHandler) { + if (this.toggle.panelButton && !this.isUnlockSession && !this._popupMenuWidgetItem) { + this._popupMenuWidgetItem = + this.toggle.panelButton.addDevice(this.path, this.alias, this._dataHandler); + } + + this._startIndicator(); + } else if (this.toggle.enableIndicator && this.indicatorMode === 1) { + this._startIndicator(); + } + } + + _startIndicator() { + if (!this.device.connected || this.indicator || !this.deviceIcon) + return; + + if (!this.toggle.indicatorEnabled || this.indicatorMode === 0) + return; + + this.indicator = new BluetoothIndicator(this); + this.toggle.addIndicatorWidget(this.indicator); + + if (!this.hoverModeEnabled || this._onHoverMenu) + return; + + if (this.isUnlockSession || this.indicatorMode !== 2) + return; + + this._onHoverMenu = new OnHoverMenu(this.indicator, this.settings, this.gIcon, + this.path, this.alias, this.widgetInfo, this._dataHandler); + } + + _recordTimeEvent() { + let stateChanged = false; + + if (this._stateSignalId) + this._client._client.disconnect(this._stateSignalId); + + this._stateSignalId = this._client._client.connect( + 'notify::default-adapter-state', () => { + stateChanged = true; + if (this._eventTimerId) { + GLib.Source.remove(this._eventTimerId); + this._eventTimerId = null; + } + this._client._client.disconnect(this._stateSignalId); + } + ); + + if (this._eventTimerId) + GLib.source_remove(this._eventTimerId); + + this._eventTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_LOW, 3, () => { + if (this.toggle && !stateChanged) { + const currentTime = GLib.DateTime.new_now_utc().to_unix(); + const props = this.toggle.deviceList.get(this.path); + if (this.device.connected) { + this.toggle.deviceList + .set(this.path, {...props, connectedTime: currentTime}); + } else { + this.toggle.deviceList + .set(this.path, {...props, disconnectedTime: currentTime}); + } + this.toggle.delayedUpdateDeviceGsettings(); + } + + if (this._stateSignalId) + this._client._client.disconnect(this._stateSignalId); + this._stateSignalId = null; + this._eventTimerId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _destroyIndicator() { + this._onHoverMenu?.destroy(); + this._onHoverMenu = null; + this.indicator?.destroy(); + this.indicator = null; + } + + _destroyOnDisconnect() { + this._dataHandler = null; + this.toggle?.panelButton?.removeDevice(this.path); + this._popupMenuWidgetItem = null; + this._destroyIndicator(); + } + + destroy() { + if (this._eventTimerId) + GLib.source_remove(this._eventTimerId); + this._eventTimerId = null; + if (this._stateSignalId) + this._client?._client?.disconnect(this._stateSignalId); + this._stateSignalId = null; + this.settings?.disconnectObject(this); + this.device?.disconnectObject(this); + this.disconnectObject(this); + this._destroyOnDisconnect(); + this.popupMenuItem?.destroy(); + this.popupMenuItem = null; + this._client = null; + this.settings = null; + this.toggle = null; + this.device = null; + } +}); diff --git a/bbm/lib/widgetManagerEnhanced.js b/bbm/lib/widgetManagerEnhanced.js new file mode 100644 index 0000000000..4d9c0efcf5 --- /dev/null +++ b/bbm/lib/widgetManagerEnhanced.js @@ -0,0 +1,243 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; + +import {BluetoothIndicator} from './widgets/bluetoothIndicator.js'; +import {BluetoothGnomePopupMenuItem} from './widgets/bluetoothGnomePopupMenu.js'; +import {BluetoothPopupMenuItem} from './widgets/bluetoothPopupMenu.js'; +import {BluetoothPopupSubMenuItem} from './widgets/bluetoothPopupSubMenu.js'; +import {OnHoverMenu} from './widgets/onHoverMenu.js'; +import {MultimodeIndicator} from './widgets/multimodeIndicator.js'; + +export const WidgetManagerEnhanced = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_WidgetManagerEnhanced', + Properties: { + 'batteryPercentage': GObject.ParamSpec.int('batteryPercentage', '', 'Battery Percentage', + GObject.ParamFlags.READWRITE, 0, 100, 0), + }, +}, class WidgetManagerEnhanced extends GObject.Object { + _init(toggle, device, qsLevelEnabled, indicatorMode, deviceIcon, enhancedDeviceProps) { + super._init(); + this.widgetManagerEnhanced = true; + this.toggle = toggle; + this.settings = toggle.settings; + this.path = device.get_object_path(); + this.device = device; + this.deviceIcon = deviceIcon; + this.alias = device.alias; + this.gIcon = toggle.gIcon; + this.type = enhancedDeviceProps?.type; + this._dataHandler = enhancedDeviceProps?.dataHandler; + this.widgetInfo = toggle.widgetInfo; + this._enableMultimodeIndicator = toggle.multimodeIndicatorEnabled; + this.hoverModeEnabled = toggle.hoverModeEnabled; + this.isUnlockSession = this.widgetInfo.isUnlockSession; + this.qsLevelEnabled = qsLevelEnabled; + this.indicatorMode = indicatorMode; + this._client = toggle._bluetoothToggle._client; + + if (this._dataHandler) + this._updateUI(); + + if (!this.toggle.modifyQuickSettings) + this.popupMenuItem = new BluetoothGnomePopupMenuItem(this); + else if (this.toggle.usePopupInQuickSettings) + this.popupMenuItem = new BluetoothPopupSubMenuItem(this); + else + this.popupMenuItem = new BluetoothPopupMenuItem(this); + + + this.device.connectObject( + 'notify::alias', () => this._aliasUpdated(this.alias), + 'notify::connected', () => { + if (!this.device.connected) { + this._destroyOnDisconnect(); + this.batteryPercentage = 0; + } + this._recordTimeEvent(); + }, + this + ); + } + + setDataHandler(dataHandler) { + if (!this._dataHandler && dataHandler) { + this._dataHandler = dataHandler; + this._setDevIcon(); + this._updateUI(); + this.popupMenuItem?.setDataHandler?.(dataHandler); + } + } + + _setDevIcon() { + if (this._dataHandler) { + const extIcon = this._dataHandler.getConfig().commonIcon; + if (this.deviceIcon !== extIcon) { + this.deviceIcon = extIcon; + this.popupMenuItem?.updateProperties(this.qsLevelEnabled, this.deviceIcon); + const props = this.toggle.deviceList.get(this.path); + this.toggle.deviceList.set(this.path, {...props, icon: extIcon}); + this.toggle.delayedUpdateDeviceGsettings(); + } + } + } + + update() { + const props = this.toggle.deviceList.get(this.path); + if (!props) + return; + + const qsChanged = this.qsLevelEnabled !== props.qsLevelEnabled; + const indicatorChanged = this.indicatorMode !== props.indicatorMode; + + if (qsChanged) { + this.qsLevelEnabled = props.qsLevelEnabled; + this.popupMenuItem?.updateProperties(this.qsLevelEnabled, this.deviceIcon); + } + + if (indicatorChanged) { + this.indicatorMode = props.indicatorMode; + this.indicator?.updateProperties(this.indicatorMode, this.deviceIcon); + this._destroyIndicator(); + + if (this.indicatorMode !== 0) + this._startIndicator(); + } + } + + + _aliasUpdated(alias) { + if (this._onHoverMenu) + this._onHoverMenu.updateAlias(alias); + if (this._enableMultimodeIndicator) + this.indicator?.updateAlias(alias); + if (this.toggle.panelButton) + this._popupMenuWidgetItem?.updateAlias(alias); + this.popupMenuItem?.updateAlias?.(alias); + } + + _updateUI() { + if (this._dataHandler && this.device.connected) { + this._dataHandler.connectObject( + 'configuration-changed', () => { + if (this.type) { + this._setDevIcon(); + this.indicator?.updateProperties(this.indicatorMode, this.deviceIcon); + } + }, + 'properties-changed', () => { + const battProps = this._dataHandler.getProps(); + this.batteryPercentage = battProps.computedBatteryLevel; + }, + this + ); + + const battProps = this._dataHandler.getProps(); + this.batteryPercentage = battProps.computedBatteryLevel; + + if (this.toggle.panelButton && !this.isUnlockSession) { + this._popupMenuWidgetItem = + this.toggle.panelButton.addDevice(this.path, this.alias, this._dataHandler); + } + + this._startIndicator(); + } + } + + _startIndicator() { + if (!this.device.connected || this.indicator || !this.deviceIcon || !this._dataHandler) + return; + + if (!this.toggle.indicatorEnabled || this.indicatorMode === 0) + return; + + if (this._enableMultimodeIndicator && this.indicatorMode === 2) { + this.indicator = new MultimodeIndicator(this, this._dataHandler); + return; + } + + this.indicator = new BluetoothIndicator(this); + this.toggle.addIndicatorWidget(this.indicator); + + if (this.hoverModeEnabled && !this.isUnlockSession && !this._onHoverMenu) { + this._onHoverMenu = new OnHoverMenu(this.indicator, this.settings, this.gIcon, + this.path, this.alias, this.widgetInfo, this._dataHandler); + } + } + + _recordTimeEvent() { + let stateChanged = false; + + if (this._stateSignalId) + this._client._client.disconnect(this._stateSignalId); + + this._stateSignalId = this._client._client.connect( + 'notify::default-adapter-state', () => { + stateChanged = true; + if (this._eventTimerId) { + GLib.Source.remove(this._eventTimerId); + this._eventTimerId = null; + } + this._client._client.disconnect(this._stateSignalId); + } + ); + + if (this._eventTimerId) + GLib.source_remove(this._eventTimerId); + + this._eventTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_LOW, 3, () => { + if (this.toggle && !stateChanged) { + const currentTime = GLib.DateTime.new_now_utc().to_unix(); + const props = this.toggle.deviceList.get(this.path); + if (this.device.connected) { + this.toggle.deviceList + .set(this.path, {...props, connectedTime: currentTime}); + } else { + this.toggle.deviceList + .set(this.path, {...props, disconnectedTime: currentTime}); + } + this.toggle.delayedUpdateDeviceGsettings(); + } + + if (this._stateSignalId) + this._client._client.disconnect(this._stateSignalId); + this._stateSignalId = null; + this._eventTimerId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _destroyIndicator() { + this._onHoverMenu?.destroy(); + this._onHoverMenu = null; + this.indicator?.destroy(); + this.indicator = null; + } + + _destroyOnDisconnect() { + this._dataHandler?.disconnectObject(this); + this._dataHandler = null; + this.toggle?.panelButton?.removeDevice(this.path); + this._popupMenuWidgetItem = null; + this._destroyIndicator(); + } + + destroy() { + if (this._eventTimerId) + GLib.source_remove(this._eventTimerId); + this._eventTimerId = null; + if (this._stateSignalId) + this._client?._client?.disconnect(this._stateSignalId); + this._stateSignalId = null; + this.settings?.disconnectObject(this); + this.device?.disconnectObject(this); + this.disconnectObject(this); + this._destroyOnDisconnect(); + this.popupMenuItem?.destroy(); + this.popupMenuItem = null; + this._client = null; + this.settings = null; + this.toggle = null; + this.device = null; + } +}); diff --git a/bbm/lib/widgets/batterySetWidget.js b/bbm/lib/widgets/batterySetWidget.js new file mode 100644 index 0000000000..eb26a39768 --- /dev/null +++ b/bbm/lib/widgets/batterySetWidget.js @@ -0,0 +1,135 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; + +import {CircleBatteryIcon} from './circleBatteryIconWidget.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +export const BatterySetWidget = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BatterySetWidget', +}, class BatterySetWidget extends St.BoxLayout { + _init(widgetInfo, dataHandler) { + super._init(); + this._dataHandler = dataHandler; + this._config = this._dataHandler.getConfig(); + const orientation = {x_align: Clutter.ActorAlign.START, ...boxLayoutProps}; + const boxStyleClass = 'bbm-battery-vertical-widget-box'; + const labelStyleClass = 'bbm-battery-veritcal-widget-label'; + + const theme = St.ThemeContext.get_for_stage(global.stage); + const batteryCanvasSize = theme.get_scale_factor() * 34; + + this.add_style_class_name(boxStyleClass); + + if (this._config.battery1Icon) { + this._battery1BatteryBox = new St.BoxLayout(orientation); + const battery1VectorPath = this._config.battery1Icon; + this._battery1BatteryIcon = new CircleBatteryIcon( + batteryCanvasSize, battery1VectorPath, widgetInfo); + this._battery1PercentageLabel = new St.Label({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._battery1PercentageLabel.add_style_class_name(labelStyleClass); + + this._battery1BatteryBox.add_child(this._battery1BatteryIcon); + this._battery1BatteryBox.add_child(this._battery1PercentageLabel); + this._battery1BatteryIcon.updateValues(0, false); + this.add_child(this._battery1BatteryBox); + } + + if (this._config.battery2Icon) { + const spaceBin1 = new St.Bin({style_class: 'bbm-battery-vertical-space-bin'}); + this.add_child(spaceBin1); + this._battery2BatteryBox = new St.BoxLayout(orientation); + const battery2VectorPath = this._config.battery2Icon; + this._battery2BatteryIcon = new CircleBatteryIcon( + batteryCanvasSize, battery2VectorPath, widgetInfo); + this._battery2PercentageLabel = new St.Label({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._battery2PercentageLabel.add_style_class_name(labelStyleClass); + this._battery2BatteryBox.add_child(this._battery2BatteryIcon); + this._battery2BatteryBox.add_child(this._battery2PercentageLabel); + this._battery2BatteryIcon.updateValues(0, false); + this.add_child(this._battery2BatteryBox); + } + + if (this._config.battery3Icon) { + const spaceBin2 = new St.Bin({style_class: 'bbm-battery-vertical-space-bin'}); + this.add_child(spaceBin2); + this._battery3BatteryBox = new St.BoxLayout(orientation); + const battery3VectorPath = this._config.battery3Icon; + this._battery3BatteryIcon = new CircleBatteryIcon( + batteryCanvasSize, battery3VectorPath, widgetInfo); + this._battery3PercentageLabel = new St.Label({ + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }); + this._battery3PercentageLabel.add_style_class_name(labelStyleClass); + this._battery3BatteryBox.add_child(this._battery3BatteryIcon); + this._battery3BatteryBox.add_child(this._battery3PercentageLabel); + this._battery3BatteryIcon.updateValues(0, false); + this.add_child(this._battery3BatteryBox); + } + + const props = this._dataHandler.getProps(); + this._updateProps(props); + + this._dataHandler.connectObject( + 'properties-changed', () => { + const properties = this._dataHandler.getProps(); + this._updateProps(properties); + }, + this + ); + } + + _updateProps(props) { + if (this._config.battery1Icon) { + if (this._config.battery1ShowOnDisconnect || + props.battery1Level !== 0 && props.battery1Status !== 'disconnected') { + this._battery1BatteryIcon.updateValues(props.battery1Level, props.battery1Status); + this._battery1PercentageLabel.text = + props.battery1Level === 0 && this._config.battery1ShowOnDisconnect + ? '' : `${props.battery1Level}%`; + this._battery1BatteryBox.visible = true; + } else { + this._battery1BatteryBox.visible = false; + } + } + + if (this._config.battery2Icon) { + if (this._config.battery2ShowOnDisconnect || + props.battery2Level !== 0 && props.battery2Status !== 'disconnected') { + this._battery2BatteryIcon.updateValues(props.battery2Level, props.battery2Status); + this._battery2PercentageLabel.text = + props.battery2Level === 0 && this._config.battery2ShowOnDisconnect + ? '' : `${props.battery2Level}%`; + this._battery2BatteryBox.visible = true; + } else { + this._battery2BatteryBox.visible = false; + } + } + + if (this._config.battery3Icon) { + if (this._config.battery3ShowOnDisconnect || + props.battery3Level !== 0 && props.battery3Status !== 'disconnected') { + this._battery3BatteryIcon.updateValues(props.battery3Level, props.battery3Status); + this._battery3PercentageLabel.text = + props.battery3Level === 0 && this._config.battery3ShowOnDisconnect + ? '' : `${props.battery3Level}%`; + this._battery3BatteryBox.visible = true; + } else { + this._battery3BatteryBox.visible = false; + } + } + } +}); diff --git a/bbm/lib/widgets/bluetoothGnomePopupMenu.js b/bbm/lib/widgets/bluetoothGnomePopupMenu.js new file mode 100644 index 0000000000..79b9e8b50c --- /dev/null +++ b/bbm/lib/widgets/bluetoothGnomePopupMenu.js @@ -0,0 +1,89 @@ +'use strict'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import {Spinner} from 'resource:///org/gnome/shell/ui/animation.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +export const BluetoothGnomePopupMenuItem = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BluetoothGnomePopupMenuItem', +}, class BluetoothGnomePopupMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(manager) { + super._init({ + style_class: 'bt-device-item', + }); + this._manager = manager; + this._toggle = manager.toggle; + this._client = this._toggle._bluetoothToggle._client; + this._gIcon = manager.gIcon; + this._device = manager.device; + this._iconType = manager.deviceIcon; + + this._icon = new St.Icon({ + style_class: 'popup-menu-icon', + }); + this._icon.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + this.add_child(this._icon); + + this._label = new St.Label({ + x_expand: true, + }); + this.add_child(this._label); + + this._subtitle = new St.Label({ + style_class: 'device-subtitle', + }); + this.add_child(this._subtitle); + + this._spinner = new Spinner(16, {hideOnStop: true}); + this.add_child(this._spinner); + + this._spinner.bind_property('visible', + this._subtitle, 'visible', + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.INVERT_BOOLEAN); + + this._device.bind_property('connectable', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this._device.bind_property('alias', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + this._device.bind_property_full('connected', + this._subtitle, 'text', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source ? _('Disconnect') : _('Connect')], + null); + + this.connect('destroy', () => (this._spinner = null)); + this.connect('activate', () => this._toggleConnected().catch(logError)); + this._device.connectObject( + 'notify::alias', () => this._updateAccessibleName(), + 'notify::connected', () => this._updateAccessibleName(), + this); + this._updateAccessibleName(); + } + + async _toggleConnected() { + this._spinner.play(); + await this._client.toggleDevice(this._device); + this._spinner?.stop(); + } + + _updateAccessibleName() { + this.accessible_name = this._device.connected + // Translators: %s is a device name like "MyPhone" + ? _('Disconnect %s').format(this._device.alias) + // Translators: %s is a device name like "MyPhone" + : _('Connect to %s').format(this._device.alias); + } + + updateProperties(_qsLevelEnabled, deviceIcon) { + if (this._iconType !== deviceIcon) { + this._iconType = deviceIcon; + this._icon?.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + } + } +}); + diff --git a/bbm/lib/widgets/bluetoothIndicator.js b/bbm/lib/widgets/bluetoothIndicator.js new file mode 100644 index 0000000000..41ad97f7ec --- /dev/null +++ b/bbm/lib/widgets/bluetoothIndicator.js @@ -0,0 +1,151 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import Pango from 'gi://Pango'; +import St from 'gi://St'; +import * as QuickSettings from 'resource:///org/gnome/shell/ui/quickSettings.js'; + +import {IndicatorIconWidget} from './indicatorIconWidget.js'; + +export const BluetoothIndicator = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BluetoothIndicator', +}, class BluetoothIndicator extends QuickSettings.SystemIndicator { + _init(manager) { + super._init(); + this._manager = manager; + this._settings = manager.settings; + this._indicatorMode = manager.indicatorMode; + this._gIcon = manager.gIcon; + this._deviceIcon = manager.deviceIcon; + this._widgetInfo = manager.widgetInfo; + this._iconSize = null; + + this._disableLevelInIcon = this._widgetInfo.disableLevelInIcon; + this._indicatorWithText = this._widgetInfo.indicatorWithText; + + if (this._disableLevelInIcon) + this._iconWithoutBatteryLevel(); + else + this._iconWithBatteryLevel(); + } + + _iconWithoutBatteryLevel() { + this._indicator = new St.Icon(); + this._indicator.gicon = this._gIcon(`bbm-${this._deviceIcon}-symbolic.svg`); + this._indicator.style_class = 'system-status-icon'; + this.add_child(this._indicator); + + this._manager.bind_property_full('batteryPercentage', + this._indicator, 'visible', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, !(source <= 0)], null); + + this._syncIndicatorsVisible(); + + this._indicator.connectObject('notify::visible', () => + this._syncIndicatorsVisible(), this); + + if (this._indicatorWithText) + this._addTextToIndicator(); + } + + _iconWithBatteryLevel() { + this._indicator = new St.Bin(); + this._indicator.style_class = 'system-status-icon'; + this.add_child(this._indicator); + this._syncIndicatorsVisible(); + + this._indicator.connectObject('notify::visible', () => + this._syncIndicatorsVisible(), this); + + this._levelWidget = new IndicatorIconWidget(this._settings, this._indicator, + this._indicatorMode, this._deviceIcon, this._widgetInfo); + + this._indicator.set_child(this._levelWidget); + + const themenode = this._indicator.peek_theme_node(); + if (themenode === null) { + this._indicatorStyleChangeId = this._indicator.connect('style-changed', () => { + const node = this._indicator.peek_theme_node(); + if (node !== null) { + if (this._indicatorStyleChangeId) + this._indicator.disconnect(this._indicatorStyleChangeId); + this._indicatorStyleChangeId = null; + this._getWidgetSize(node); + } + }); + } else { + this._getWidgetSize(themenode); + } + + this.connectObject( + 'destroy', () => { + if (this._indicatorStyleChangeId) + this._indicator.disconnect(this._indicatorStyleChangeId); + this._indicatorStyleChangeId = null; + this._indicator?.destroy(); + }, + this + ); + + + if (this._indicatorMode === 1) { + this._indicator.visible = true; + } else { + this._manager.bind_property_full('batteryPercentage', + this._indicator, 'visible', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, !(source <= 0)], null); + } + + this._manager.connectObject( + 'notify::batteryPercentage', () => { + this._levelWidget?.updateValues(this._manager.batteryPercentage); + }, + this + ); + + this._levelWidget.updateValues(this._manager.batteryPercentage); + + if (this._indicatorWithText && this._indicatorMode === 2) + this._addTextToIndicator(); + } + + _getWidgetSize(themeNode) { + const [found, iconSize] = themeNode.lookup_length('icon-size', false); + if (found) + this._levelWidget.setIconSize(iconSize); + } + + _addTextToIndicator() { + this._percentageLabel = new St.Label({ + y_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + const clutterText = this._percentageLabel.get_clutter_text(); + clutterText.ellipsize = Pango.EllipsizeMode.NONE; + this.add_child(this._percentageLabel); + this.add_style_class_name('power-status'); + const formatter = new Intl.NumberFormat(undefined, {style: 'percent'}); + this._percentageLabel.visible = false; + + this._manager.bind_property_full('batteryPercentage', + this._percentageLabel, 'text', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, formatter.format(source / 100)], null); + + this._manager.bind_property_full('batteryPercentage', + this._percentageLabel, 'visible', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source > 0], null); + } + + updateProperties(indicatorMode, deviceIcon) { + this._indicatorMode = indicatorMode; + this._deviceIcon = deviceIcon; + if (this._disableLevelInIcon) + this._indicator.gicon = this._gIcon(`bbm-${deviceIcon}-symbolic.svg`); + else + this._levelWidget?.updateProperties(indicatorMode, deviceIcon); + } +}); diff --git a/bbm/lib/widgets/bluetoothPopupMenu.js b/bbm/lib/widgets/bluetoothPopupMenu.js new file mode 100644 index 0000000000..110eb35937 --- /dev/null +++ b/bbm/lib/widgets/bluetoothPopupMenu.js @@ -0,0 +1,229 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +export const BluetoothPopupMenuItem = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BluetoothPopupMenuItem', +}, class BluetoothPopupMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(manager) { + super._init({ + style_class: 'bt-device-item', + }); + this._manager = manager; + this._toggle = manager.toggle; + this._settings = manager.settings; + this._client = this._toggle._bluetoothToggle._client; + this._gIcon = manager.gIcon; + this._showBatteryPercentage = this._toggle.showBatteryPercentage; + this._showBatteryIcon = this._toggle.showBatteryIcon; + this._swapIconText = this._toggle.swapIconText; + this._connectedColor = this._toggle.connectedColor; + this._disconnectedColor = null; + this._device = manager.device; + this._iconType = manager.deviceIcon; + this._qsLevelEnabled = manager.qsLevelEnabled; + + const themeNode = this.peek_theme_node(); + if (themeNode === null) { + this._backgroundStyleChangeId = this.connect('style-changed', () => { + const node = this.peek_theme_node(); + if (node !== null) { + if (this._backgroundStyleChangeId) + this.disconnect(this._backgroundStyleChangeId); + this._backgroundStyleChangeId = null; + this._buildUI(node); + } + }); + } else { + this._buildUI(themeNode); + } + } + + _buildUI(themeNode) { + this._disconnectedColor = themeNode.get_foreground_color(); + this._icon = new St.Icon({ + style_class: 'popup-menu-icon', + }); + this._icon.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + this.add_child(this._icon); + + this._label = new St.Label({ + x_expand: true, + }); + this.add_child(this._label); + + if (this._swapIconText) { + this._displayBatteryLevelIcon(); + this._displayBatteryLevelTextPercentage(); + } else { + this._displayBatteryLevelTextPercentage(); + this._displayBatteryLevelIcon(); + } + + this._pairIcon = new St.Icon({ + style_class: 'popup-menu-icon bbm-bt-pair-widget', + }); + this.add_child(this._pairIcon); + + this._device.bind_property('connectable', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this._device.bind_property('alias', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + + this._device.bind_property_full('connected', + this, 'accessible_name', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source ? _('Disconnect') : _('Connect')], + null); + + this._assignPairingIcon(false); + + this.activate = __ => { + this._toggleConnected().catch(logError); + }; + + this._device.connectObject( + 'notify::connected', () => { + this._assignPairingIcon(false); + }, + this + ); + + this.connectObject('destroy', () => { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._iconChangeTimerId = null; + if (this._backgroundStyleChangeId) + this.disconnect(this._backgroundStyleChangeId); + this._backgroundStyleChangeId = null; + if (this._labelStyleChangeId && this._batteryPercentageLabel) + this._batteryPercentageLabel.disconnect(this._labelStyleChangeId); + this._labelStyleChangeId = null; + }, this); + } + + updateProperties(qsLevelEnabled, deviceIcon) { + this._qsLevelEnabled = qsLevelEnabled; + if (this._showBatteryIcon && this._batteryIcon) { + this._batteryIcon.visible = + this._qsLevelEnabled && this._manager.batteryPercentage > 0; + } + if (this._showBatteryPercentage && this._batteryPercentageLabel) { + this._batteryPercentageLabel.visible = + this._qsLevelEnabled && this._manager.batteryPercentage > 0; + } + if (this._iconType !== deviceIcon) { + this._iconType = deviceIcon; + this._icon?.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + } + } + + _displayBatteryLevelTextPercentage() { + if (this._showBatteryPercentage) { + this._batteryPercentageLabel = new St.Label({text: '100%'}); + this.add_child(this._batteryPercentageLabel); + if (this._batteryPercentageLabel.get_stage()) { + this._bindLabel(); + } else { + this._labelStyleChangeId = this._batteryPercentageLabel.connect( + 'style-changed', () => { + const isStaged = this.get_stage(); + if (isStaged) { + if (this._labelStyleChangeId) + this._batteryPercentageLabel.disconnect(this._labelStyleChangeId); + this._labelStyleChangeId = null; + this._bindLabel(); + } + } + ); + } + } + } + + _bindLabel() { + if (this._swapIconText) { + this._batteryPercentageLabel + .set_width(this._batteryPercentageLabel.get_width()); + this._batteryPercentageLabel.style_class = 'bbm-bt-percentage-label'; + } + this._batteryPercentageLabel.text = ''; + + this._manager.bind_property_full('batteryPercentage', + this._batteryPercentageLabel, 'visible', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, this._qsLevelEnabled && source > 0], null); + + this._manager.bind_property_full('batteryPercentage', + this._batteryPercentageLabel, 'text', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, `${source}%`], null); + } + + _displayBatteryLevelIcon() { + if (this._showBatteryIcon) { + this._batteryIcon = new St.Icon({ + style_class: 'popup-menu-icon', + }); + this.add_child(this._batteryIcon); + + this._manager.bind_property_full('batteryPercentage', + this._batteryIcon, 'visible', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, this._qsLevelEnabled && source > 0], null); + + this._manager.bind_property_full('batteryPercentage', + this._batteryIcon, 'icon-name', + GObject.BindingFlags.SYNC_CREATE, + (bind, source) => [true, source <= 0 ? '' + : `battery-level-${10 * Math.floor(source / 10)}-symbolic`], + null); + } + } + + async _toggleConnected() { + this._assignPairingIcon(true); + await this._client.toggleDevice(this._device); + this._assignPairingIcon(false); + } + + _assignPairingIcon(toggleActivated) { + if (toggleActivated) { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._counter = 4; + if (!this._device.connected) + this._pairIcon?.set_style(`color: ${this._connectedColor};`); + else + this._pairIcon?.set_style(`color: ${this._disconnectedColor};`); + + const connected = this._device.connected; + this._iconChangeTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + this._pairIcon?.set_gicon(connected + ? this._gIcon(`bbm-bluetooth-disconnecting-${this._counter}-symbolic.svg`) + : this._gIcon(`bbm-bluetooth-connecting-${this._counter}-symbolic.svg`)); + this._counter = this._counter <= 1 ? 4 : this._counter - 1; + return GLib.SOURCE_CONTINUE; + }); + } else { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._iconChangeTimerId = null; + if (this._device.connected) { + this._pairIcon?.set_gicon(this._gIcon('bbm-bluetooth-connected-symbolic.svg')); + this._pairIcon?.set_style(`color: ${this._connectedColor};`); + } else { + this._pairIcon?.set_gicon( + this._gIcon('bbm-bluetooth-disconnecting-1-symbolic.svg')); + this._pairIcon?.set_style(`color: ${this._disconnectedColor};`); + } + } + } +}); + + diff --git a/bbm/lib/widgets/bluetoothPopupSubMenu.js b/bbm/lib/widgets/bluetoothPopupSubMenu.js new file mode 100644 index 0000000000..3e443232e0 --- /dev/null +++ b/bbm/lib/widgets/bluetoothPopupSubMenu.js @@ -0,0 +1,395 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {PopupMenuWidgetBox} from './popupMenuWidget.js'; +import {Tooltip} from './tooltip.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +export const BluetoothPopupSubMenuItem = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BluetoothPopupSubMenuItem', +}, class BluetoothPopupSubMenuItem extends PopupMenu.PopupBaseMenuItem { + _init(manager) { + super._init({style_class: 'bt-device-item bbm-submenu', can_focus: false}); + this._manager = manager; + this._toggle = manager.toggle; + this._settings = manager.settings; + this._client = this._toggle._bluetoothToggle._client; + this._gIcon = manager.gIcon; + this._connectedColor = this._toggle.connectedColor; + this._disconnectedColor = null; + this._device = manager.device; + this._iconType = manager.deviceIcon; + this._widgetInfo = manager.widgetInfo; + this._qsLevelEnabled = manager.qsLevelEnabled; + this._dataHandler = null; + + this._menu = this._toggle._bluetoothToggle._deviceSection || null; + this._isOpen = false; + + const themeNode = this.peek_theme_node(); + if (themeNode === null) { + this._backgroundStyleChangeId = this.connect('style-changed', () => { + const node = this.peek_theme_node(); + if (node !== null) { + if (this._backgroundStyleChangeId) + this.disconnect(this._backgroundStyleChangeId); + this._backgroundStyleChangeId = null; + this._buildUI(node); + } + }); + } else { + this._buildUI(themeNode); + } + } + + _buildUI(themeNode) { + const fgColor = themeNode.get_foreground_color(); + this._colorInfo = { + accentColor: this._widgetInfo.accentColor, + foregroundColor: fgColor, + }; + + this._disconnectedColor = fgColor; + + const showTooltips = this._settings.get_boolean('enable-tooltip'); + if (showTooltips) + this._tooltip = new Tooltip(this); + + this._vBox = new St.BoxLayout({...boxLayoutProps, x_expand: true}); + this.add_child(this._vBox); + + this._hBox = new St.BoxLayout({x_expand: true}); + this._vBox.add_child(this._hBox); + + this._icon = new St.Icon({style_class: 'popup-menu-icon'}); + + this._icon.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + this._hBox.add_child(this._icon); + + this._label = new St.Label({ + style_class: 'bbm-bt-widget', + y_align: Clutter.ActorAlign.CENTER, + }); + this._hBox.add_child(this._label); + + const expander = new St.Bin({style_class: 'popup-menu-item-expander', x_expand: true}); + this._hBox.add_child(expander); + + this._triangle = + new St.Icon({icon_name: 'pan-down-symbolic', style_class: 'bbm-header-icon'}); + + this._triangleButton = new St.Button({ + style_class: 'button bbm-header-button', + can_focus: true, y_align: Clutter.ActorAlign.CENTER, child: this._triangle, + }); + this._updateTriangleButtonAccesibleName(); + + this._triangleBin = new St.Bin({ + style_class: 'bbm-header-bin', + y_align: Clutter.ActorAlign.CENTER, child: this._triangleButton, + }); + + this._triangleBin.visible = false; + this._hBox.add_child(this._triangleBin); + + this._pairIcon = new St.Icon({style_class: 'bbm-header-icon'}); + this._pairButton = new St.Button({ + style_class: 'button bbm-header-button', + can_focus: true, y_align: Clutter.ActorAlign.CENTER, child: this._pairIcon, + }); + this._updatePairButtonAccesibleName(); + + const pairBin = new St.Bin({ + style_class: 'bbm-header-bin', + y_align: Clutter.ActorAlign.CENTER, child: this._pairButton, + }); + + this._hBox.add_child(pairBin); + + this._device.bind_property('connectable', + this, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + this._device.bind_property('alias', + this._label, 'text', + GObject.BindingFlags.SYNC_CREATE); + + this._assignPairingIcon(false); + + if (this._manager._dataHandler) + this.setDataHandler(this._manager._dataHandler); + + this.activate = __ => {}; + + this._triangleButton.connectObject('clicked', () => { + const expandFocused = this._triangleButton.has_key_focus(); + const nextFocus = expandFocused ? this._popupItemBox.collapseButton : null; + this._toggleSubMenuBox(true, true, nextFocus); + }, this); + + this._pairButton.connectObject('clicked', () => { + if (this._device.connected && this._isOpen) + this._toggleSubMenuBox(false, true, null); + + this._toggleConnected().catch(logError); + }, this); + + if (this._menu) { + this._menu.connectObject('open-state-changed', (o, isOpen) => { + if (!isOpen && this._menu._bbmOpenSubmenu) { + this._menu._bbmOpenSubmenu._toggleSubMenuBox(false, false, null); + this._menu._bbmOpenSubmenu = null; + } + }, this); + } + + this._device.connectObject( + 'notify::connected', () => { + this._assignPairingIcon(false); + if (!this._device.connected && this._dataHandler) { + this._toggleSubMenuBox(false, false, null); + this._triangleBin.visible = false; + this._dataHandler = null; + this._removePopupItemBox(); + } + this._updatePairButtonAccesibleName(); + }, + this + ); + + this.connectObject('destroy', () => { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._iconChangeTimerId = null; + if (this._backgroundStyleChangeId) + this.disconnect(this._backgroundStyleChangeId); + this._backgroundStyleChangeId = null; + this._tooltip?.destroy(); + this._tooltip = null; + }, this); + } + + updateProperties(qsLevelEnabled, deviceIcon) { + if (this._qsLevelEnabled !== qsLevelEnabled) { + this._qsLevelEnabled = qsLevelEnabled; + this.setDataHandler(this._dataHandler); + } + + if (deviceIcon && this._iconType !== deviceIcon) { + this._iconType = deviceIcon; + if (this._icon) + this._icon.set_gicon(this._gIcon(`bbm-${this._iconType}-symbolic.svg`)); + } + } + + async _toggleConnected() { + this._assignPairingIcon(true); + await this._client.toggleDevice(this._device); + this._assignPairingIcon(false); + } + + _assignPairingIcon(toggleActivated) { + if (toggleActivated) { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._counter = 4; + + const connected = this._device.connected; + this._iconChangeTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 300, () => { + const gioIcon = connected + ? this._gIcon(`bbm-bluetooth-disconnecting-${this._counter}-symbolic.svg`) + : this._gIcon(`bbm-bluetooth-connecting-${this._counter}-symbolic.svg`); + + const style = connected ? `color: ${this._connectedColor};` + : `color: ${this._disconnectedColor};`; + + this._pairIcon?.set_gicon(gioIcon); + this._pairIcon?.set_style(style); + this._popupItemBox?.btPairButton?.icon?.set_gicon(gioIcon); + this._popupItemBox?.btPairButton?.icon?.set_style(style); + + this._counter = this._counter <= 1 ? 4 : this._counter - 1; + return GLib.SOURCE_CONTINUE; + }); + } else { + if (this._iconChangeTimerId) + GLib.source_remove(this._iconChangeTimerId); + this._iconChangeTimerId = null; + + const connected = this._device.connected; + const gioIcon = connected ? this._gIcon('bbm-bluetooth-connected-symbolic.svg') + : this._gIcon('bbm-bluetooth-disconnecting-1-symbolic.svg'); + + const style = connected ? `color: ${this._connectedColor};` + : `color: ${this._disconnectedColor};`; + + this._pairIcon?.set_gicon(gioIcon); + this._pairIcon?.set_style(style); + this._popupItemBox?.btPairButton?.icon?.set_gicon(gioIcon); + this._popupItemBox?.btPairButton?.icon?.set_style(style); + } + } + + _addPopupItemBox(dataHandler) { + if (this._popupItemBox) + return; + + const headerButtons = {pin: false, btPair: true, collapse: true}; + this._popupItemBox = new PopupMenuWidgetBox(this._settings, this._gIcon, + this._device.get_object_path(), this._device.alias, this._manager.widgetInfo, + this._colorInfo, headerButtons, dataHandler); + this._popupItemBox.visible = false; + this._vBox.add_child(this._popupItemBox); + + this._popupItemBox.collapseButton.connectObject('clicked', () => { + if (this._isOpen) { + const expandFocused = this._popupItemBox.collapseButton.has_key_focus(); + const nextFocus = expandFocused ? this._triangleButton : null; + this._toggleSubMenuBox(false, true, nextFocus); + } + }, this._popupItemBox); + + this._popupItemBox.btPairButton.connectObject('clicked', () => { + if (this._device.connected && this._isOpen) { + const btPairFocused = this._popupItemBox.btPairButton.has_key_focus(); + const nextFocus = btPairFocused ? this._pairButton : null; + this._toggleSubMenuBox(false, true, nextFocus); + } + this._toggleConnected().catch(logError); + }, this._popupItemBox); + + this._assignPairingIcon(false); + } + + _removePopupItemBox() { + this._popupItemBox?.collapseButton?.disconnectObject(this._popupItemBox); + this._popupItemBox?.btPairButton?.disconnectObject(this._popupItemBox); + this._popupItemBox?.destroy(); + this._popupItemBox = null; + } + + setDataHandler(dataHandler) { + if (!dataHandler) + return; + if (this._triangleButton && this._device.connected && this._qsLevelEnabled) { + this._triangleBin.visible = true; + this._addPopupItemBox(dataHandler); + } else if (this._triangleButton && !this._qsLevelEnabled) { + this._triangleBin.visible = false; + this._removePopupItemBox(); + } + this._dataHandler = dataHandler; + } + + _updateTriangleButtonAccesibleName() { + if (this._triangleButton) { + const btnName = _('Collapse submenu'); + this._triangleButton?.set_accessible_name(`${this._device.alias} ${btnName}`); + this._tooltip?.attach(this._triangleButton, btnName); + } + } + + _updatePairButtonAccesibleName() { + if (this._pairButton) { + const status = this._device.connected ? _('Disconnect') : _('Connect'); + this._pairButton?.set_accessible_name(`${this._device.alias} ${status}`); + this._tooltip?.attach(this._pairButton, status); + } + } + + updateAlias(alias) { + this._popupItemBox?.updateAlias?.(alias); + this._updateTriangleButtonAccesibleName(); + this._updatePairButtonAccesibleName(); + } + + _toggleSubMenuBox(open, animate, nextGrabCall, doneCallback = null) { + if (open) { + const openNow = () => { + this.active = true; + this.add_style_class_name('open bbm-bt-open-menu'); + this._hBox.hide(); + this._popupItemBox.opacity = 0; + this._popupItemBox.visible = true; + const [, naturalHeight] = this._popupItemBox.get_preferred_height(-1); + this._popupItemBox.height = 0; + const duration = animate ? 200 : 0; + this.set_track_hover(false); + + this._popupItemBox.ease({ + height: naturalHeight, + opacity: 255, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._popupItemBox.set_height(-1); + this.set_track_hover(true); + if (nextGrabCall) + this._popupItemBox.collapseButton.grab_key_focus(); + if (doneCallback) + doneCallback(); + }, + }); + + this._isOpen = true; + if (this._menu) + this._menu._bbmOpenSubmenu = this; + }; + + if (this._menu && this._menu._bbmOpenSubmenu && this._menu._bbmOpenSubmenu !== this) { + const other = this._menu._bbmOpenSubmenu; + other._toggleSubMenuBox(false, true, false, () => { + openNow(); + }); + return; + } + + openNow(); + return; + } + + if (!this._isOpen) { + if (doneCallback) + doneCallback(); + return; + } + + this.active = false; + this.remove_style_class_name('open bbm-bt-open-menu'); + + const duration = animate ? 100 : 0; + this._hBox.show(); + this.set_track_hover(false); + + this._popupItemBox.ease({ + height: 0, + opacity: 0, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._popupItemBox.visible = false; + this._popupItemBox.set_height(-1); + this._isOpen = false; + if (this._menu && this._menu._bbmOpenSubmenu === this) + this._menu._bbmOpenSubmenu = null; + this.set_track_hover(true); + if (nextGrabCall) + nextGrabCall.grab_key_focus(); + if (doneCallback) + doneCallback(); + }, + }); + } +}); + + diff --git a/bbm/lib/widgets/checkButtonBin.js b/bbm/lib/widgets/checkButtonBin.js new file mode 100644 index 0000000000..57b0ac02d6 --- /dev/null +++ b/bbm/lib/widgets/checkButtonBin.js @@ -0,0 +1,194 @@ +'use strict'; +import Atk from 'gi://Atk'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; + +import {adjustColorLuminanceToRgba, colorToRgba, colorGreyOpacity} from './colorHelpers.js'; + +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const buttonMask = shellVersion >= 51 ? St.ButtonMask.PRIMARY : St.ButtonMask.ONE; +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +const CheckButton = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_CheckButton', +}, class CheckButton extends St.BoxLayout { + _init(gIcon, styleInfo, checkBtnTitle, initialChecked) { + super._init({...boxLayoutProps, x_expand: true}); + + this._gIcon = gIcon; + this._styleInfo = styleInfo; + + this._checkBoxLabel = new St.Label({ + style_class: 'bbm-subtitle-label', + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.CENTER, + x_expand: true, + }); + this._checkBoxLabel.text = checkBtnTitle; + this.add_child(this._checkBoxLabel); + + this._focusBin = new St.Bin({ + x_expand: true, + style_class: 'button bbm-checkbtn-bin', + }); + this.add_child(this._focusBin); + + this.checkButton = new St.Button({ + style_class: 'bbm-checkbtn', + reactive: true, + can_focus: true, + track_hover: true, + button_mask: buttonMask, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + accessible_role: Atk.Role.CHECK_BOX, + accessible_name: checkBtnTitle, + }); + + this._bin = new St.Bin({ + style_class: 'bbm-checkbtn-box', + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.CENTER, + }); + this.checkButton.child = this._bin; + + this._icon = new St.Icon({ + gicon: this._gIcon('bbm-check-symbolic.svg'), + style_class: 'bbm-checkbtn-icon', + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.CENTER, + }); + this._bin.set_child(this._icon); + + this.checkButton.checked = initialChecked; + + this.updateStyle(); + this.checkButton.connectObject( + 'clicked', () => { + this.checkButton.checked = !this.checkButton.checked; + + this.updateStyle(); + }, + 'notify::hover', () => { + this.updateStyle(); + }, + 'key-focus-in', () => { + this.updateStyle(); + }, + 'key-focus-out', () => { + this.updateStyle(); + }, + this + ); + + this._focusBin.set_child(this.checkButton); + } + + updateStyle() { + let iconStyle = ''; + let boxStyle = ''; + + if (this.checkButton.checked && this.checkButton.hover) { + iconStyle = this._styleInfo.iconHoverCheckedStyle; + boxStyle = + `${this._styleInfo.borderHoverStyle} ${ + this._styleInfo.boxHoverCheckedStyle}`; + } else if (this.checkButton.checked) { + iconStyle = this._styleInfo.iconCheckedStyle; + boxStyle = + `${this._styleInfo.borderCheckedStyle} ${ + this._styleInfo.boxCheckedStyle}`; + } else if (this.checkButton.hover) { + iconStyle = this._styleInfo.iconStyle; + boxStyle = + `${this._styleInfo.borderHoverStyle} ${ + this._styleInfo.boxStyle}`; + } else { + iconStyle = this._styleInfo.iconStyle; + boxStyle = + `${this._styleInfo.borderStyle} ${ + this._styleInfo.boxStyle}`; + } + + if (this.checkButton.has_key_focus()) + this._focusBin.add_style_pseudo_class('focus'); + else + this._focusBin.remove_style_pseudo_class('focus'); + + + this._icon.set_style(iconStyle); + this._bin.set_style(boxStyle); + } +}); + +export const CheckButtonBin = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_CheckButtonBin', +}, class CheckButtonBin extends St.BoxLayout { + _init(gIcon, colorInfo, dataHandler, id) { + super._init({ + x_expand: true, style_class: 'bbm-box-checkbtn', + }); + + const accentColor = colorInfo.accentColor; + const accentRgba = colorToRgba(accentColor); + const fgColor = colorInfo.foregroundColor; + const fgRgba = colorToRgba(fgColor); + + const hoverAccentColor = adjustColorLuminanceToRgba(accentColor, 5); + const hoverAccentFgColor = adjustColorLuminanceToRgba(fgColor, 5); + + const borderStyle = `border: 2px solid ${colorGreyOpacity(0.50)};`; + const borderHoverStyle = `border: 2px solid ${colorGreyOpacity(0.70)};`; + const borderCheckedStyle = 'border: 2px solid transparent;'; + + const boxStyle = 'background-color: transparent;'; + const boxCheckedStyle = `background-color: ${accentRgba};`; + const boxHoverCheckedStyle = `background-color: ${hoverAccentColor};`; + + const iconStyle = 'color: transparent;'; + const iconCheckedStyle = `color: ${fgRgba};`; + const iconHoverCheckedStyle = `color: ${hoverAccentFgColor};`; + + const styleInfo = { + borderStyle, borderHoverStyle, borderCheckedStyle, + boxStyle, boxCheckedStyle, boxHoverCheckedStyle, + iconStyle, iconCheckedStyle, iconHoverCheckedStyle, + }; + + const vBox = new St.BoxLayout({ + ...boxLayoutProps, x_expand: true, + x_align: Clutter.ActorAlign.FILL, + }); + this.add_child(vBox); + + const checkLabels = dataHandler.config[`box${id}CheckButton`]; + this._buttons = []; + + checkLabels.forEach((label, i) => { + const btnIndex = i + 1; + const state = dataHandler.getProps()[`box${id}CheckButton${btnIndex}State`]; + const btn = new CheckButton(gIcon, styleInfo, label, state > 0); + vBox.add_child(btn); + this._buttons.push(btn); + + btn.checkButton.connectObject('clicked', () => { + const checked = btn.checkButton.checked ? 1 : 0; + dataHandler.emitUIAction(`box${id}CheckButton${btnIndex}State`, checked); + }, this); + }); + + dataHandler.connectObject('properties-changed', () => { + this._buttons.forEach((btn, idx) => { + const state = dataHandler.getProps()[`box${id}CheckButton${idx + 1}State`]; + btn.checkButton.checked = state > 0; + btn.updateStyle(); + }); + }, this); + } +}); diff --git a/bbm/lib/widgets/circleBatteryIconWidget.js b/bbm/lib/widgets/circleBatteryIconWidget.js new file mode 100644 index 0000000000..84a073742d --- /dev/null +++ b/bbm/lib/widgets/circleBatteryIconWidget.js @@ -0,0 +1,199 @@ +'use strict'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import Cairo from 'gi://cairo'; + +import { + hexToColor, setSourceColor, getInkBounds, addVectorImage, loadFileToCairoSurface +} from './colorHelpers.js'; +import {VectorImages} from './circularBatteryVectorImages.js'; + +// Credits: to https://github.com/Deminder for this +// https://github.com/Deminder/battery-indicator-icon/blob/main/src/modules/drawicon.js + +export const CircleBatteryIcon = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_CircleBatteryIcon', +}, class CircleBatteryIcon extends St.DrawingArea { + _init(canvasSize, deviceIcon, widgetInfo) { + super._init({x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER}); + this.width = canvasSize; + this.height = canvasSize; + this._canvasSize = canvasSize; + this._deviceIcon = deviceIcon; + this._widgetInfo = widgetInfo; + this._extPath = widgetInfo.extPath; + this._loadDeviceIcon(); + } + + _loadDeviceIcon() { + this._cairoCacheSurface = null; + this._transform = {}; + + const intendedIconSize = 15.2; + const svgSize = 16; + const unscaledCanvasSize = 34; + + const iconFolder = `${this._extPath}/icons/hicolor/scalable/actions`; + const filePath = `${iconFolder}/bbm-${this._deviceIcon}-symbolic.svg`; + + const inkRect = getInkBounds(filePath, svgSize); + if (!inkRect) + return; + + const intendedScale = intendedIconSize / svgSize; + const displayScale = this._canvasSize / unscaledCanvasSize; + const maxTextureScale = 3; + const scale = displayScale * intendedScale / maxTextureScale; + const unscaledOffsetXY = (unscaledCanvasSize - intendedIconSize) / 2; + const offsetXY = unscaledOffsetXY * maxTextureScale / intendedScale; + + this._transform.scale = scale; + this._transform.offsetXY = offsetXY; + this._transform.chargingStatusScale = displayScale; + + this._cairoCacheSurface = loadFileToCairoSurface(filePath, maxTextureScale); + } + + _assignWidgetColor() { + const themeColors = this.get_theme_node().get_icon_colors(); + const foregroundColor = themeColors.foreground; + const successColor = themeColors.success; + const warningColor = themeColors.warning; + const errorColor = themeColors.error; + const baseLevelColor = foregroundColor.copy(); + baseLevelColor.alpha *= 0.5; + + let fillLevelColor; + let chargingIconColor = foregroundColor; + const disconnectedIconColor = errorColor; + if (this._widgetInfo.circleWidgetColor === 0) { + fillLevelColor = this._percentage > 20 ? foregroundColor : warningColor; + chargingIconColor = successColor; + } else if (this._widgetInfo.circleWidgetColor === 1) { + fillLevelColor = this._percentage > 20 ? successColor : warningColor; + } else { + const idx = Math.ceil(this._percentage / 10) - 1; + const fallbackColor = this._percentage > 20 ? successColor : warningColor; + const hex = this._widgetInfo.circleWidgetCustomColors[idx] ?? fallbackColor; + fillLevelColor = hexToColor(hex); + } + return { + foregroundColor, fillLevelColor, baseLevelColor, + chargingIconColor, disconnectedIconColor, + }; + } + + updateValues(percentage, status) { + this._status = status; + this._percentage = percentage; + if (this.has_allocation()) + this.queue_repaint(); + } + + _drawIcon(cr) { + cr.save(); + cr.scale(this._transform.scale, this._transform.scale); + cr.translate(this._transform.offsetXY, this._transform.offsetXY); + setSourceColor(cr, this._colors.foregroundColor); + cr.maskSurface(this._cairoCacheSurface, 0, 0); + cr.restore(); + } + + _setRadialStrokeSource(cr, cx, cy, radius, strokeWidth, scale, color) { + let fade = 0.2 - 0.091 * Math.log(scale); + fade = Math.max(0.10, Math.min(0.20, fade)); + + const inner = radius - strokeWidth / 2; + const outer = radius + strokeWidth / 2; + + const gradient = new Cairo.RadialGradient(cx, cy, inner, cx, cy, outer); + + const r = color.red / 255; + const g = color.green / 255; + const b = color.blue / 255; + const a = color.alpha / 255; + + gradient.addColorStopRGBA(0.0, r, g, b, 0.0); + gradient.addColorStopRGBA(fade, r, g, b, a); + gradient.addColorStopRGBA(1.0 - fade, r, g, b, a); + gradient.addColorStopRGBA(1.0, r, g, b, 0.0); + + cr.setSource(gradient); + } + + _drawCircle(cr) { + const size = this._canvasSize; + const scale = size / 32; + const strokeWidth = 4.8 * Math.pow(scale, 0.85); + + const p = Math.max(0, Math.min(1, this._percentage / 100)); + const radius = (size - strokeWidth) / 2; + const cx = size / 2; + const cy = size / 2; + const angleOffset = -0.5 * Math.PI; + const endAngle = angleOffset + p * 2 * Math.PI; + + cr.save(); + cr.setLineWidth(strokeWidth); + + if (p > 0) { + this._setRadialStrokeSource(cr, cx, cy, radius, strokeWidth, + scale, this._colors.fillLevelColor); + cr.arc(cx, cy, radius, angleOffset, endAngle); + cr.stroke(); + } + + if (p < 1) { + this._setRadialStrokeSource(cr, cx, cy, radius, strokeWidth, + scale, this._colors.baseLevelColor); + cr.arc(cx, cy, radius, endAngle, angleOffset + 2 * Math.PI); + cr.stroke(); + } + + cr.restore(); + } + + _drawChargingStatusVectors(cr) { + if (this._status !== 'disconnected' && this._status !== 'charging') + return; + + cr.scale(this._transform.chargingStatusScale, this._transform.chargingStatusScale); + + const modelPath = VectorImages[this._deviceIcon]; + const chargingPath = VectorImages['charging-bolt']; + const disconnectedPath = VectorImages['disconnected']; + + addVectorImage(cr, modelPath, this._colors.foregroundColor); + + if (this._status === 'disconnected') { + cr.fill(); + addVectorImage(cr, disconnectedPath, this._colors.disconnectedIconColor); + } else if (this._status === 'charging') { + addVectorImage(cr, chargingPath, this._colors.chargingIconColor); + } + cr.fill(); + } + + vfunc_repaint() { + if (!this._cairoCacheSurface) + return; + + const [w, h] = this.get_surface_size(); + if (w === 0 || h === 0) + return; + + if (this._transform.scale === undefined || this._transform.offsetXY === undefined || + this._transform.chargingStatusScale === undefined) + return; + + this._colors = this._assignWidgetColor(); + const cr = this.get_context(); + this._drawIcon(cr); + this._drawCircle(cr); + cr.$dispose(); + } +} +); + + diff --git a/bbm/lib/widgets/circularBatteryVectorImages.js b/bbm/lib/widgets/circularBatteryVectorImages.js new file mode 100644 index 0000000000..f0cf9456ed --- /dev/null +++ b/bbm/lib/widgets/circularBatteryVectorImages.js @@ -0,0 +1,14 @@ +'use strict'; + +/* eslint-disable max-len */ +export const VectorImages = {}; + +// 32x32px canvas + +// charging-status +VectorImages['charging-bolt'] = 'M 30.36421 11.986995 L 23.87228 22.98943 L 26.58152 22.32545 L 25.50804 32 L 32 20.99756 L 29.29076 21.6615 Z'; + +VectorImages['disconnected'] = 'M 20.118952 22.187372 C 20.088342 22.217982 20.088342 22.267282 20.118952 22.297892 L 23.869062 26.047992 L 20.118952 29.798102 C 20.088342 29.828712 20.088342 29.878012 20.118952 29.908622 L 22.187372 31.977042 C 22.217982 32.007652 22.267282 32.007652 22.297892 31.977042 L 26.047992 28.226932 L 29.798102 31.977042 C 29.828712 32.007652 29.878012 32.007652 29.908622 31.977042 L 31.977042 29.908622 C 32.007652 29.878012 32.007652 29.828712 31.977042 29.798102 L 28.226932 26.047992 L 31.977042 22.297892 C 32.007652 22.267282 32.007652 22.217982 31.977042 22.187372 L 29.908622 20.118952 C 29.878012 20.088342 29.828712 20.088342 29.798102 20.118952 L 26.047992 23.869062 L 22.297892 20.118952 C 22.267282 20.088342 22.217982 20.088342 22.187372 20.118952 Z'; + +/* eslint-enable max-len */ + diff --git a/bbm/lib/widgets/colorHelpers.js b/bbm/lib/widgets/colorHelpers.js new file mode 100644 index 0000000000..9a2ce68d42 --- /dev/null +++ b/bbm/lib/widgets/colorHelpers.js @@ -0,0 +1,236 @@ +import Clutter from 'gi://Clutter'; +import Cogl from 'gi://Cogl'; +import Gio from 'gi://Gio'; +import St from 'gi://St'; +import Rsvg from 'gi://Rsvg'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); + +export function getAccentColor() { + const tmp = new St.Button({style_class: 'quick-toggle'}); + tmp.hide(); + Main.uiGroup.add_child(tmp); + tmp.checked = true; + const color = tmp.get_theme_node().get_background_color(); + tmp.destroy(); + return color; +} + +export function rgbToHsl(r, g, b) { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h, s; + const l = (max + min) / 2; + + if (max === min) { + h = s = 0; + } else { + const delta = max - min; + s = l > 0.5 ? delta / (2 - max - min) : delta / (max + min); + switch (max) { + case r: + h = (g - b) / delta + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / delta + 2; + break; + case b: + h = (r - g) / delta + 4; + break; + } + h /= 6; + } + + return { + h: h * 360, + s: s * 100, + l: l * 100, + }; +} + +export function hslToRgb(h, s, l) { + h /= 360; + s /= 100; + l /= 100; + let r, g, b; + + if (s === 0) { + r = g = b = l; + } else { + const hue2rgb = (p, q, t) => { + if (t < 0) + t += 1; + if (t > 1) + t -= 1; + if (t < 1 / 6) + return p + (q - p) * 6 * t; + if (t < 1 / 2) + return q; + if (t < 2 / 3) + return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + + r = hue2rgb(p, q, h + 1 / 3); + g = hue2rgb(p, q, h); + b = hue2rgb(p, q, h - 1 / 3); + } + + return { + red: Math.round(r * 255), + green: Math.round(g * 255), + blue: Math.round(b * 255), + }; +} + +export function hslToRgbHexString(h, s, l) { + const {red, green, blue} = hslToRgb(h, s, l); + return `#${((1 << 24) + (red << 16) + (green << 8) + blue) + .toString(16) + .slice(1) + .toUpperCase()}`; +} + +export function colorToString(color) { + return color.to_string().substring(0, 7); +} + +export function colorToRgba(color) { + return `rgba(${color.red}, ${color.green}, ${color.blue}, ${color.alpha})`; +} + +export function colorToHsl(color) { + return rgbToHsl(color.red, color.green, color.blue); +} + +export function isDarkMode(color) { + const {l} = rgbToHsl(color.red, color.green, color.blue); + return l < 40; +} + +export function invertColor(color) { + const ColorClass = shellVersion <= 46 ? Clutter.Color : Cogl.Color; + return new ColorClass({ + red: 255 - color.red, + green: 255 - color.green, + blue: 255 - color.blue, + alpha: color.alpha, + }); +} + +export function adjustOpacityToRgba(color, transparency) { + const alpha = Math.max(0, Math.min(1, transparency)).toFixed(2); + return `rgba(${color.red}, ${color.green}, ${color.blue}, ${alpha})`; +} + +export function colorGreyOpacity(transparency) { + const alpha = Math.max(0, Math.min(1, transparency)).toFixed(2); + return `rgba(${128}, ${128}, ${128}, ${alpha})`; +} + +export function adjustColorLuminanceToRgba(color, factor) { + const {h, s, l} = rgbToHsl(color.red, color.green, color.blue); + const newL = Math.max(0, Math.min(100, l + factor)); + const {red, green, blue} = hslToRgb(h, s, newL); + const alpha = (color.alpha / 255).toFixed(2); + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +export function hexToColor(hexColor) { + return (shellVersion <= 46 ? Clutter.Color : Cogl.Color).from_string(hexColor)[1]; +} + +export function setSourceColor(cr, sourceColor) { + if (Clutter.cairo_set_source_color) + Clutter.cairo_set_source_color(cr, sourceColor); + else + cr.setSourceColor(sourceColor); +} + +export function getInkBounds(filePath, svgSize) { + let handle; + + try { + handle = Rsvg.Handle.new_from_file(filePath); + } catch { + console.error(`Failed to load SVG: ${filePath}`); + return null; + } + + const intrinsic = handle.get_intrinsic_size_in_pixels(); + if (!Array.isArray(intrinsic) || intrinsic.length < 3 || + intrinsic[1] !== svgSize || intrinsic[2] !== svgSize) { + console.log('Invalid SVG dimension'); + return null; + } + + const viewport = new Rsvg.Rectangle({x: 0, y: 0, width: svgSize, height: svgSize}); + const [ok, inkRect] = handle.get_geometry_for_layer(null, viewport); + if (!ok) { + console.log('Invalid SVG geometry'); + return null; + } + return inkRect; +} + +export function loadFileToCairoSurface(filePath, textureScale) { + try { + const gFile = Gio.File.new_for_path(filePath); + return St.TextureCache.get_default().load_file_to_cairo_surface( + gFile, 1, textureScale); + } catch (e) { + console.log(e, `Failed to load Cairo surface from file: ${filePath}`); + return null; + } +} + +export function addVectorImage(cr, path, color) { + setSourceColor(cr, color); + cr.translate(0, 0); + let currentX = 0; + let currentY = 0; + const vectorPath = path.split(' '); + for (let i = 0; i < vectorPath.length; i++) { + if (vectorPath[i] === 'M') { + currentX = parseFloat(vectorPath[i + 1]); + currentY = parseFloat(vectorPath[i + 2]); + cr.moveTo(currentX, currentY); + i += 2; + } else if (vectorPath[i] === 'L') { + currentX = parseFloat(vectorPath[i + 1]); + currentY = parseFloat(vectorPath[i + 2]); + cr.lineTo(currentX, currentY); + i += 2; + } else if (vectorPath[i] === 'H') { + currentX = parseFloat(vectorPath[i + 1]); + cr.lineTo(currentX, currentY); + i += 1; + } else if (vectorPath[i] === 'V') { + currentY = parseFloat(vectorPath[i + 1]); + cr.lineTo(currentX, currentY); + i += 1; + } else if (vectorPath[i] === 'C') { + const x1 = parseFloat(vectorPath[i + 1]); + const y1 = parseFloat(vectorPath[i + 2]); + const x2 = parseFloat(vectorPath[i + 3]); + const y2 = parseFloat(vectorPath[i + 4]); + const x3 = parseFloat(vectorPath[i + 5]); + const y3 = parseFloat(vectorPath[i + 6]); + cr.curveTo(x1, y1, x2, y2, x3, y3); + currentX = x3; + currentY = y3; + i += 6; + } else if (vectorPath[i] === 'Z') { + cr.closePath(); + } + } + cr.fill(); +} diff --git a/bbm/lib/widgets/iconGroups.js b/bbm/lib/widgets/iconGroups.js new file mode 100644 index 0000000000..d1dc2f5821 --- /dev/null +++ b/bbm/lib/widgets/iconGroups.js @@ -0,0 +1,31 @@ +export const supportedIcons = [ + 'audio-card', 'audio-speakers', 'audio-speakers2', 'audio-speakers3', + 'audio-headphones', 'audio-headset', 'headphone1', + 'earbuds', 'earbuds-wingtip', 'earbuds-wingtip2', 'earbuds-neckband', 'earbuds-stem', + 'earbuds-stem2', 'earbuds-stem3', 'earbuds-bean', + 'input-microphone', + 'input-gaming', 'input-gaming2', 'input-gaming3', + 'input-keyboard', 'input-keyboard2', 'input-split-keyboard', + 'input-mouse', 'input-tablet', 'touchpad', + 'phone', 'phone-samsung-galaxy-s', 'phone-apple-iphone', 'phone-google-nexus-one', + 'multimedia-player', 'pda', 'camera-photo', 'camera-video', 'computer', 'video-display', + 'printer', 'scanner', 'modem', 'network-wireless', + 'wearable', 'wearable2', + 'upower-devices', 'bluetooth-gui', +]; + +export const supportedAudioSingleIcons = [ + 'audio-headphones', 'audio-headset', 'headphone1', + 'earbuds', 'earbuds-wingtip', 'earbuds-wingtip2', 'earbuds-neckband', 'earbuds-stem', + 'earbuds-stem2', 'earbuds-stem3', 'earbuds-bean', +]; + +export const supportedAudioDualIcons = [ + 'earbuds', 'earbuds-wingtip', 'earbuds-wingtip2', 'earbuds-stem', + 'earbuds-stem2', 'earbuds-stem3', 'earbuds-bean', +]; + +export const supportedCaseIcons = [ + 'case-normal', 'case-narrow', 'case-oval', 'case-oval-short', 'case-round', 'case-square', +]; + diff --git a/bbm/lib/widgets/indicatorIconWidget.js b/bbm/lib/widgets/indicatorIconWidget.js new file mode 100644 index 0000000000..c657da4380 --- /dev/null +++ b/bbm/lib/widgets/indicatorIconWidget.js @@ -0,0 +1,369 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; + +import { + hexToColor, setSourceColor, getInkBounds, addVectorImage, + loadFileToCairoSurface +} from './colorHelpers.js'; +import {VectorImages} from './indicatorVectorImages.js'; + +// Credits: to https://github.com/Deminder for this +// https://github.com/Deminder/battery-indicator-icon/blob/main/src/modules/drawicon.js + +export const IndicatorIconWidget = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_IndicatorIconWidget', +}, class IndicatorIconWidget extends St.DrawingArea { + _init(settings, indicator, indicatorMode, deviceIcon, widgetInfo) { + super._init({x_align: Clutter.ActorAlign.CENTER, y_align: Clutter.ActorAlign.CENTER}); + this._settings = settings; + this._indicator = indicator; + this._indicatorScale = this._settings.get_int('indicator-size') / 100; + this._indicatorMode = indicatorMode; + this._iconSize = null; + this._extPath = widgetInfo.extPath; + this._deviceIcon = deviceIcon; + this._widgetInfo = widgetInfo; + this._cairoCacheSurface = null; + + this._settings.connectObject( + 'changed::indicator-size', () => { + this._indicatorScale = this._settings.get_int('indicator-size') / 100; + this._loadDeviceIcon(); + }, + this + ); + } + + setIconSize(iconSize) { + this._iconSize = iconSize; + this._loadDeviceIcon(); + } + + _loadDeviceIcon() { + if (!this._iconSize) + return; + + this._cairoCacheSurface = null; + this._transform = {}; + const svgSize = 16; + const iconFolder = `${this._extPath}/icons/hicolor/scalable/actions`; + const filePath = `${iconFolder}/bbm-${this._deviceIcon}-symbolic.svg`; + + const inkRect = getInkBounds(filePath, svgSize); + if (!inkRect) + return; + + if (this._widgetInfo.levelIndicatorType === 1 || this._widgetInfo.levelBarPosition === 2) + this._setSquaredWidth(filePath, svgSize, inkRect); + else + this._setAdjustedWidth(filePath, svgSize, inkRect); + } + + _setSquaredWidth(filePath, svgSize, inkRect) { + const binWidth = this._iconSize; + const binHeight = this._iconSize; + const displayScale = binHeight / svgSize; + const maxTextureScale = 3; + const maxHeight = 12; + const maxWidth = 14.5; + let scaleSvg = maxHeight / inkRect.height; + const targetWidth = inkRect.width * scaleSvg; + + if (targetWidth >= maxWidth) + scaleSvg = maxWidth / inkRect.width; + + const textureSize = svgSize * maxTextureScale; + const correctedFactor = binHeight / textureSize; + const finalScale = scaleSvg * correctedFactor * this._indicatorScale; + const inkWidthSurface = inkRect.width * maxTextureScale; + const inkHeightSurface = inkRect.height * maxTextureScale; + const inkRectXSurface = inkRect.x * maxTextureScale; + const inkRectYSurface = inkRect.y * maxTextureScale; + const inkWidthDevice = inkWidthSurface * finalScale; + const inkHeightDevice = inkHeightSurface * finalScale; + const desiredInkLeftDevice = (binHeight - inkWidthDevice) / 2; + const maxHeightDevice = maxHeight * displayScale; + const desiredInkTopDevice = maxHeightDevice - inkHeightDevice; + const offsetX = desiredInkLeftDevice / finalScale - inkRectXSurface; + const offsetY = desiredInkTopDevice / finalScale - inkRectYSurface; + const batteryScale = displayScale; + + this.width = binWidth; + this.height = binHeight; + + this._transform = { + scale: finalScale, + offsetX, + offsetY, + batteryScale, + }; + + const batteryWidth = 15; + const batteryHeight = 2.6; + const batteryX = (svgSize - batteryWidth) / 2; + const batteryY = svgSize - batteryHeight; + + if (this._widgetInfo.levelIndicatorType === 0) + this._precomputeBarLayouts(batteryX, batteryY, batteryWidth, batteryHeight, false); + else + this._precomputeDotLayouts(batteryX, batteryY, batteryWidth, batteryHeight); + + this._cairoCacheSurface = loadFileToCairoSurface(filePath, maxTextureScale); + + this.queue_repaint(); + } + + _setAdjustedWidth(filePath, svgSize, inkRect) { + const binHeight = this._iconSize; + const spacer = this._indicatorMode === 1 ? 2 : 3; + const batBoxWidth = this._indicatorMode === 1 ? 6.5 : 4; + const batteryHeight = 15; + const svgHeight = svgSize; + const displayScale = binHeight / svgSize; + const maxTextureScale = 3; + const textureSize = svgSize * maxTextureScale; + const correctedFactor = binHeight / textureSize; + const icon2Right = this._widgetInfo.levelBarPosition === 0; + const initialOffset = icon2Right ? spacer + batBoxWidth : 0; + const offsetX = (initialOffset - inkRect.x) * maxTextureScale; + const offsetY = (0 - inkRect.y + (svgHeight - inkRect.height) / 2) * maxTextureScale; + const totalBinWidth = (inkRect.width + spacer + batBoxWidth) * displayScale; + const batteryScale = displayScale; + + this.width = totalBinWidth; + this.height = binHeight; + + this._transform = { + scale: correctedFactor, + offsetX, + offsetY, + batteryScale, + }; + + const batteryX = icon2Right ? 0 : inkRect.width + spacer; + const batteryY = (svgHeight - batteryHeight) / 2; + + this._precomputeBarLayouts(batteryX, batteryY, batBoxWidth, batteryHeight, true); + + this._cairoCacheSurface = loadFileToCairoSurface(filePath, maxTextureScale); + + this.queue_repaint(); + } + + _assignWidgetColor() { + const themeColors = this.get_theme_node().get_icon_colors(); + const foregroundColor = themeColors.foreground; + const successColor = themeColors.success; + const warningColor = themeColors.warning; + const baseLevelColor = foregroundColor.copy(); + baseLevelColor.alpha *= 0.5; + + let fillLevelColor; + if (this._widgetInfo.levelIndicatorColor === 0) { + fillLevelColor = this._percentage > 20 ? foregroundColor : warningColor; + } else if (this._widgetInfo.levelIndicatorColor === 1) { + fillLevelColor = this._percentage > 20 ? successColor : warningColor; + } else { + const idx = Math.ceil(this._percentage / 10) - 1; + const fallbackColor = this._percentage > 20 ? successColor : warningColor; + const hex = this._widgetInfo.levelIndicatorCustomColors[idx] ?? fallbackColor; + fillLevelColor = hexToColor(hex); + } + return {foregroundColor, fillLevelColor, baseLevelColor}; + } + + _precomputeBarLayouts(x, y, width, height, vertical = false) { + this._batteryBounds = {}; + const thickness = vertical ? width : height; + const notchLength = thickness * 0.40; + const notchDepth = thickness * 0.60; + const radius = thickness * 0.06; + + let barX, barY, barW, barH; + let notchX, notchY, notchW, notchH; + + if (vertical) { + barX = x; + barY = y + notchLength; + barW = width; + barH = height - notchLength; + notchX = x + (width - notchDepth) / 2; + notchY = y; + notchW = notchDepth; + notchH = notchLength; + } else { + barX = x; + barY = y; + barW = width - notchLength; + barH = height; + notchX = x + barW; + notchY = y + (height - notchDepth) / 2; + notchW = notchLength; + notchH = notchDepth; + } + + this._batteryBounds = { + vertical, + barX, barY, barW, barH, + notchX, notchY, notchW, notchH, + radius, + }; + } + + _drawBatteryBarLevel(cr) { + const { + vertical, + barX, barY, barW, barH, + notchX, notchY, notchW, notchH, + radius, + } = this._batteryBounds; + + const drawRoundedRect = (ctx, x, y, w, h, r) => { + ctx.newPath(); + ctx.arc(x + w - r, y + r, r, -Math.PI / 2, 0); + ctx.arc(x + w - r, y + h - r, r, 0, Math.PI / 2); + ctx.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI); + ctx.arc(x + r, y + r, r, Math.PI, 1.5 * Math.PI); + ctx.closePath(); + ctx.fill(); + }; + + setSourceColor(cr, this._colors.baseLevelColor); + drawRoundedRect(cr, barX, barY, barW, barH, radius); + + if (this._percentage >= 100) + setSourceColor(cr, this._colors.fillLevelColor); + drawRoundedRect(cr, notchX, notchY, notchW, notchH, radius); + + setSourceColor(cr, this._colors.fillLevelColor); + if (vertical) { + const fillH = barH * this._percentage / 100; + const fillY = barY + (barH - fillH); + drawRoundedRect(cr, barX, fillY, barW, fillH, radius); + } else { + const fillW = barW * this._percentage / 100; + drawRoundedRect(cr, barX, barY, fillW, barH, radius); + } + } + + _precomputeDotLayouts(x, y, width, height) { + this._dotLayouts = []; + const r = height / 2; + const d = height; + const boxCenter = x + width / 2; + const cy = y + r; + this._dotLayouts = {radius: r}; + this._dotLayouts[1] = [{cx: boxCenter, cy}]; + + let spacer = d; + this._dotLayouts[2] = [ + {cx: boxCenter - (spacer / 2 + r), cy}, + {cx: boxCenter + (spacer / 2 + r), cy}, + ]; + + this._dotLayouts[3] = [ + {cx: boxCenter - (spacer + r), cy}, + {cx: boxCenter, cy}, + {cx: boxCenter + (spacer + r), cy}, + ]; + + spacer = (width - 4 * d) / 3; + const startX = x + (width - (4 * d + 3 * spacer)) / 2; + this._dotLayouts[4] = [ + {cx: startX + r, cy}, + {cx: startX + (d + spacer) + r, cy}, + {cx: startX + 2 * (d + spacer) + r, cy}, + {cx: startX + 3 * (d + spacer) + r, cy}, + ]; + + this._batteryBounds = this._batteryBounds || {}; + this._batteryBounds.barX = x; + } + + _drawDotLevel(cr) { + let fillCount = 0; + const percentage = this._percentage; + + if (percentage > 75) + fillCount = 4; + else if (percentage > 50) + fillCount = 3; + else if (percentage > 25) + fillCount = 2; + else + fillCount = 1; + + const centers = this._dotLayouts[fillCount]; + const radius = this._dotLayouts.radius; + + setSourceColor(cr, this._colors.fillLevelColor); + + for (const {cx, cy} of centers) { + cr.newPath(); + cr.arc(cx, cy, radius, 0, 2 * Math.PI); + cr.fill(); + } + } + + _drawWidget(cr) { + cr.save(); + cr.scale(this._transform.scale, this._transform.scale); + cr.translate(this._transform.offsetX, this._transform.offsetY); + setSourceColor(cr, this._colors.foregroundColor); + cr.maskSurface(this._cairoCacheSurface, 0, 0); + cr.restore(); + cr.scale(this._transform.batteryScale, this._transform.batteryScale); + + if (this._indicatorMode === 2) { + if (this._widgetInfo.levelIndicatorType === 0) + this._drawBatteryBarLevel(cr); + else + this._drawDotLevel(cr); + } else if (this._indicatorMode === 1) { + const barX = this._batteryBounds?.barX ?? 0; + cr.translate(barX, 0); + const placeImageBelowDeviceIcon = this._widgetInfo.levelIndicatorType === 1 || + this._widgetInfo.levelBarPosition === 2; + const vectorImage = placeImageBelowDeviceIcon ? 'h-non-battery' : 'v-non-battery'; + addVectorImage(cr, VectorImages[vectorImage], this._colors.foregroundColor); + } + } + + vfunc_repaint() { + if (!this._cairoCacheSurface) + return; + + const [w, h] = this.get_surface_size(); + if (w === 0 || h === 0) + return; + + if (this._transform.scale === undefined || this._transform.offsetX === undefined || + this._transform.offsetY === undefined || + this._transform.batteryScale === undefined) + return; + + this._colors = this._assignWidgetColor(); + const cr = this.get_context(); + this._drawWidget(cr); + cr.$dispose(); + } + + updateValues(percentage) { + this._percentage = percentage; + if (this.has_allocation()) + this.queue_repaint(); + } + + updateProperties(indicatorMode, deviceIcon) { + if (this._indicatorMode !== indicatorMode || this._deviceIcon !== deviceIcon) { + this._indicatorMode = indicatorMode; + this._deviceIcon = deviceIcon; + this._loadDeviceIcon(); + } + } +} +); + + diff --git a/bbm/lib/widgets/indicatorVectorImages.js b/bbm/lib/widgets/indicatorVectorImages.js new file mode 100644 index 0000000000..7ec99e93c8 --- /dev/null +++ b/bbm/lib/widgets/indicatorVectorImages.js @@ -0,0 +1,12 @@ +'use strict'; +/* eslint-disable max-len */ +export const VectorImages = {}; + +// 16x16px canvas + +VectorImages['h-non-battery'] = 'M 3.25 12.5 L 0.5 15.25 H 6 Z M 12.75 12.5 L 10 15.25 H 15.5 Z'; + +VectorImages['v-non-battery'] = 'M 2.5326976 1.5 V 5.6842895 L 1.324402 4.485431 L 0 5.8014418 L 2.2158644 8 L 0 10.198558 L 1.324402 11.514569 L 2.5326976 10.31571 V 14.5 L 6.5 10.932713 L 4.4573115 9.0973265 V 6.9065785 L 6.5 5.0672873 Z'; + + +/* eslint-enable max-len */ diff --git a/bbm/lib/widgets/labelIndicators.js b/bbm/lib/widgets/labelIndicators.js new file mode 100644 index 0000000000..7090f973f2 --- /dev/null +++ b/bbm/lib/widgets/labelIndicators.js @@ -0,0 +1,97 @@ +'use strict'; +const {GObject, St, Clutter} = imports.gi; +import {colorToRgba, invertColor} from './colorHelpers.js'; + +export const LabelIndicators = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_LabelIndicators', +}, class LabelIndicators extends St.BoxLayout { + _init(colorInfo, dataHandler) { + super._init({ + style_class: 'bbm-label-indicator-box', x_expand: true, + x_align: Clutter.ActorAlign.CENTER, + }); + + this._dataHandler = dataHandler; + const config = dataHandler.getConfig(); + const props = dataHandler.getProps(); + const count = config.labelIndicatorEnabled; + const widthEm = config.labelWidth ?? 7; + this._bins = []; + + const invertedColor = invertColor(colorInfo.foregroundColor); + + const createLabelBin = text => { + const bin = new St.Bin({ + style_class: 'bbm-label-indicator-bin', + x_expand: false, + y_expand: true, + style: `background-color: ${colorToRgba(colorInfo.foregroundColor) + }; width: ${widthEm}em;`, + }); + + const label = new St.Label({ + style_class: 'bbm-label-indicator-label', + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + style: `color: ${colorToRgba(invertedColor)};`, + }); + + bin.set_child(label); + bin._label = label; + + if (text && text.length > 0) { + label.set_text(text); + bin.show(); + } else { + bin.hide(); + } + + this.add_child(bin); + return bin; + }; + + if (count >= 1) + this._bins.push(createLabelBin(props.labelIndicator1)); + if (count >= 2) + this._bins.push(createLabelBin(props.labelIndicator2)); + if (count >= 3) + this._bins.push(createLabelBin(props.labelIndicator3)); + + this._updateBoxVisibility(); + + this._dataHandler.connectObject('properties-changed', () => this._updateLabels(), this); + } + + _updateLabels() { + const props = this._dataHandler.getProps(); + const labels = [ + props.labelIndicator1, + props.labelIndicator2, + props.labelIndicator3, + ]; + + for (let i = 0; i < this._bins.length; i++) { + const bin = this._bins[i]; + const text = labels[i]; + if (text && text.length > 0) { + bin._label.set_text(text); + bin.show(); + } else { + bin.hide(); + } + } + + this._updateBoxVisibility(); + } + + _updateBoxVisibility() { + const anyVisible = this._bins.some(b => b.visible); + if (anyVisible) + this.show(); + else + this.hide(); + } +}); + diff --git a/bbm/lib/widgets/multimodeIndicator.js b/bbm/lib/widgets/multimodeIndicator.js new file mode 100644 index 0000000000..fb533d515b --- /dev/null +++ b/bbm/lib/widgets/multimodeIndicator.js @@ -0,0 +1,183 @@ +'use strict'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; + +import {BluetoothIndicator} from './bluetoothIndicator.js'; +import {OnHoverMenu} from './onHoverMenu.js'; + +const BatteryLevelProxy = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_BatteryLevelProxy', + Properties: { + 'batteryPercentage': GObject.ParamSpec.int( + 'batteryPercentage', '', 'Battery Percentage', + GObject.ParamFlags.READWRITE, 0, 100, 0 + ), + }, +}, class BatteryLevelProxy extends GObject.Object {}); + +export const MultimodeIndicator = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_MultimodeIndicator', +}, class MultimodeIndicator extends GObject.Object { + _init(manager, dataHandler) { + super._init(); + this._manager = manager; + this._dataHandler = dataHandler; + this._toggle = manager.toggle; + this._settings = manager.settings; + this._indicatorMode = manager.indicatorMode; + this._gIcon = manager.gIcon; + this._path = manager.path; + this._alias = manager.alias; + this._widgetInfo = manager.widgetInfo; + this._hoverModeEnabled = manager.hoverModeEnabled; + this._isUnlockSession = manager.isUnlockSession; + this._config = dataHandler.getConfig(); + this._props = dataHandler.getProps(); + this._updateIndicators(); + + this._dataHandler.connectObject( + 'configuration-changed', () => { + this._config = this._dataHandler.getConfig(); + this._updateIndicators(); + }, + 'properties-changed', () => { + this._props = this._dataHandler.getProps(); + this._updateIndicators(); + }, + this + ); + } + + _addIndicatorBoxLayout() { + this._indicatorBox = new St.BoxLayout({ + style_class: 'panel-status-indicators-box', + reactive: true, + }); + this._indicatorBox.quickSettingsItems = []; + this._toggle.addIndicatorWidget(this._indicatorBox); + + if (this._hoverModeEnabled && !this._isUnlockSession && !this._onHoverMenu) { + this._onHoverMenu = new OnHoverMenu(this._indicatorBox, this._settings, this._gIcon, + this._path, this._alias, this._widgetInfo, this._dataHandler); + } + + this._indicatorBox.connectObject('child-removed', () => { + if (this._indicatorBox.get_n_children() === 0) + this._removeIndicatorBoxLayout(); + }, + this + ); + } + + _removeIndicatorBoxLayout() { + this._onHoverMenu?.destroy(); + this._onHoverMenu = null; + this._indicatorBox?.disconnectObject(this); + this._indicatorBox?.destroy(); + this._indicatorBox = null; + } + + _addIndicatorWidget(widget) { + if (!this._indicatorBox) + this._addIndicatorBoxLayout(); + this._indicatorBox.add_child(widget); + } + + _updateIndicators() { + if (this._config.battery1Icon && this._props.battery1Level > 0) { + if (!this._bat1Indicator) { + this._bat1Proxy = new BatteryLevelProxy(); + this._bat1Proxy.settings = this._settings; + this._bat1Proxy.gIcon = this._gIcon; + this._bat1Proxy.widgetInfo = this._widgetInfo; + this._bat1Proxy.indicatorMode = this._indicatorMode; + this._bat1Proxy.deviceIcon = this._config.battery1Icon; + this._bat1Proxy.batteryPercentage = this._props.battery1Level; + this._bat1Indicator = + this._addIndicator(this._bat1Proxy); + this._addIndicatorWidget(this._bat1Indicator); + } else { + this._bat1Proxy.batteryPercentage = this._props.battery1Level; + } + } else { + this._bat1Indicator?.destroy(); + this._bat1Indicator = null; + this._bat1Proxy = null; + } + + if (this._config.battery2Icon && this._props.battery2Level > 0) { + if (!this._bat2Indicator) { + this._bat2Proxy = new BatteryLevelProxy(); + this._bat2Proxy.settings = this._settings; + this._bat2Proxy.gIcon = this._gIcon; + this._bat2Proxy.widgetInfo = this._widgetInfo; + this._bat2Proxy.indicatorMode = this._indicatorMode; + this._bat2Proxy.deviceIcon = this._config.battery2Icon; + this._bat2Proxy.batteryPercentage = this._props.battery2Level; + + this._bat2Indicator = + this._addIndicator(this._bat2Proxy); + this._addIndicatorWidget(this._bat2Indicator); + } else { + this._bat2Proxy.batteryPercentage = this._props.battery2Level; + } + } else { + this._bat2Indicator?.destroy(); + this._bat2Indicator = null; + this._bat2Proxy = null; + } + + if (this._config.battery3Icon && this._props.battery3Level > 0) { + if (!this._bat3Indicator) { + this._bat3Proxy = new BatteryLevelProxy(); + this._bat3Proxy.settings = this._settings; + this._bat3Proxy.gIcon = this._gIcon; + this._bat3Proxy.widgetInfo = this._widgetInfo; + this._bat3Proxy.indicatorMode = this._indicatorMode; + this._bat3Proxy.deviceIcon = this._config.battery3Icon; + this._bat3Proxy.batteryPercentage = this._props.battery3Level; + + this._bat3Indicator = + this._addIndicator(this._bat3Proxy); + this._addIndicatorWidget(this._bat3Indicator); + } else { + this._bat3Proxy.batteryPercentage = this._props.battery3Level; + } + } else { + this._bat3Indicator?.destroy(); + this._bat3Indicator = null; + this._bat3Proxy = null; + } + } + + _addIndicator(proxy) { + return new BluetoothIndicator(proxy); + } + + // eslint-disable-next-line no-unused-vars + updateProperties(indicatorMode, deviceIcon) { + this._bat1Indicator?.updateProperties(indicatorMode, this._config.battery1Icon); + this._bat2Indicator?.updateProperties(indicatorMode, this._config.battery2Icon); + this._bat3Indicator?.updateProperties(indicatorMode, this._config.battery3Icon); + } + + updateAlias(alias) { + this._onHoverMenu?.updateAlias(alias); + } + + destroy() { + this._dataHandler.disconnectObject(this); + this._onHoverMenu?.destroy(); + this._onHoverMenu = null; + this._bat1Indicator?.destroy(); + this._bat1Indicator = null; + this._bat2Indicator?.destroy(); + this._bat2Indicator = null; + this._bat3Indicator?.destroy(); + this._bat3Indicator = null; + this._indicatorBox?.destroy(); + this._indicatorBox = null; + this._dataHandler = null; + } +}); + diff --git a/bbm/lib/widgets/onHoverMenu.js b/bbm/lib/widgets/onHoverMenu.js new file mode 100644 index 0000000000..35476b7f08 --- /dev/null +++ b/bbm/lib/widgets/onHoverMenu.js @@ -0,0 +1,109 @@ +'use strict'; +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {PopupMenuWidget} from './popupMenuWidget.js'; + +export const OnHoverMenu = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_OnHoverMenu', +}, class OnHoverMenu extends GObject.Object { + _init(box, settings, gIcon, path, alias, widgetInfo, dataHandler) { + super._init(); + this._box = box; + this._menu = new PopupMenu.PopupMenu(this._box, 0.0, St.Side.BOTTOM, 0); + Main.uiGroup.add_child(this._menu.actor); + this._menuManager = new PopupMenu.PopupMenuManager(this._box); + this._menuManager.addMenu(this._menu); + this._menu.actor.hide(); + this._popupItem = new PopupMenuWidget( + settings, gIcon, path, alias, widgetInfo, dataHandler); + this._menu.addMenuItem(this._popupItem); + this._delayValue = settings.get_int('on-hover-delay'); + settings.connectObject( + 'changed::on-hover-delay', () => { + this._delayValue = settings.get_int('on-hover-delay'); + }, + this + ); + + this._hoverTimeoutId = null; + this._enterId = null; + this._leaveId = null; + + this._connectHoverSignals(); + + this._menu.connectObject( + 'open-state-changed', (menu, open) => { + if (!open) + this._connectHoverSignals(); + }, + this + ); + } + + _connectHoverSignals() { + if (!this._enterId) + this._enterId = this._box.connect('enter-event', this._onEnter.bind(this)); + + if (!this._leaveId) + this._leaveId = this._box.connect('leave-event', this._onLeave.bind(this)); + } + + _disconnectHoverSignals() { + if (this._enterId) { + this._box.disconnect(this._enterId); + this._enterId = null; + } + if (this._leaveId) { + this._box.disconnect(this._leaveId); + this._leaveId = null; + } + } + + _onEnter() { + if (this._hoverTimeoutId || this._menu.isOpen) + return; + + this._hoverTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._delayValue, () => { + this._menu.open(); + this._disconnectHoverSignals(); + this._hoverTimeoutId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _onLeave() { + if (this._hoverTimeoutId) { + GLib.source_remove(this._hoverTimeoutId); + this._hoverTimeoutId = null; + } + } + + updateAlias(alias) { + this._popupItem?.updateAlias(alias); + } + + destroy() { + if (this._hoverTimeoutId) { + GLib.source_remove(this._hoverTimeoutId); + this._hoverTimeoutId = null; + } + this._disconnectHoverSignals(); + + if (this._menuManager && this._menu) + this._menuManager.removeMenu(this._menu); + + if (this._menu.actor) { + Main.uiGroup.remove_child(this._menu.actor); + this._menu.destroy(); + } + this._menuManager = null; + this._menu = null; + } +}); + + diff --git a/bbm/lib/widgets/optionsBox.js b/bbm/lib/widgets/optionsBox.js new file mode 100644 index 0000000000..8f35043b1e --- /dev/null +++ b/bbm/lib/widgets/optionsBox.js @@ -0,0 +1,136 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; + +import {SliderBin} from './sliderBin.js'; +import {CheckButtonBin} from './checkButtonBin.js'; +import {RadioButtonBin} from './radioButtonBin.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 ? {orientation: Clutter.Orientation.VERTICAL} + : {vertical: true}; + +export const OptionsBox = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_OptionsBox', +}, class OptionsBox extends St.Widget { + _init(gIcon, colorInfo, dataHandler) { + super._init({layout_manager: new Clutter.BinLayout(), x_expand: true}); + + this._dataHandler = dataHandler; + const config = dataHandler.getConfig(); + + const allOpts = [ + config.optionsBox1, + config.optionsBox2, + config.optionsBox3, + config.optionsBox4, + ]; + + this._boxes = allOpts.map((opts, idx) => { + if (!Array.isArray(opts) || opts.length === 0) + return null; + + const container = new St.BoxLayout({...boxLayoutProps, x_expand: true}); + + opts.forEach(opt => { + let widget; + + if (opt === 'slider') { + widget = new SliderBin(gIcon, dataHandler, idx + 1); + } else if (opt === 'check-button') { + const len = config[`box${idx + 1}CheckButton`].length; + if (len < 1 || len > 2) { + console.log('Bluetooth-Battery-Meter: bad check-button config'); + return; + } + widget = new CheckButtonBin(gIcon, colorInfo, dataHandler, idx + 1); + } else if (opt === 'radio-button') { + const len = config[`box${idx + 1}RadioButton`].length; + if (len < 2 || len > 4) { + console.log('Bluetooth-Battery-Meter: bad radio-button config'); + return; + } + widget = new RadioButtonBin(gIcon, colorInfo, dataHandler, idx + 1); + } + + if (widget) + container.add_child(widget); + }); + + this.add_child(container); + container.visible = false; + return container; + }); + + this._current = 0; + this.showUI(dataHandler.props.optionsBoxVisible); + + dataHandler.connectObject('properties-changed', () => { + this.showUI(dataHandler.props.optionsBoxVisible); + }, this); + } + + showUI(index) { + if (this._current === 0 && index > 0) { + this._showWidget(this._boxes[index - 1]); + this._current = index; + } else if (this._current > 0 && index !== this._current) { + const oldWidget = this._boxes[this._current - 1]; + const newWidget = this._boxes[index - 1]; + this._hideWidget(oldWidget, newWidget); + this._current = index; + } else if (this._current > 0 && index === 0) { + const oldWidget = this._boxes[this._current - 1]; + this._hideWidget(oldWidget, null); + this._current = index; + } + } + + _showWidget(widget) { + if (!widget) + return; + + widget.visible = true; + widget.set_pivot_point(0.5, 0.5); + widget.opacity = 0; + widget.scale_x = 0.8; + widget.scale_y = 0.8; + widget.reactive = true; + + widget.ease({ + opacity: 255, + scale_x: 1.0, + scale_y: 1.0, + duration: 250, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } + + _hideWidget(widget, nextWidget) { + if (!widget) + return; + + widget.set_pivot_point(0.5, 0.5); + + widget.ease({ + opacity: 255, + scale_x: 0.8, + scale_y: 0.8, + duration: 50, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + widget.reactive = false; + widget.opacity = 0; + widget.scale_x = 1.0; + widget.scale_y = 1.0; + if (nextWidget) + this._showWidget(nextWidget); + widget.visible = false; + }, + }); + } +}); + diff --git a/bbm/lib/widgets/panelPopupMenu.js b/bbm/lib/widgets/panelPopupMenu.js new file mode 100644 index 0000000000..beebf7fe53 --- /dev/null +++ b/bbm/lib/widgets/panelPopupMenu.js @@ -0,0 +1,223 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {PopupMenuWidgetBox} from './popupMenuWidget.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +export const PanelPopupMenu = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_PanelPopupMenu', +}, class PanelPopupMenu extends PopupMenu.PopupBaseMenuItem { + _init(settings, gIcon, path, alias, widgetInfo, dataHandler, menu, showPinButton) { + super._init(); + this._settings = settings; + this._gIcon = gIcon; + this._path = path; + this._alias = alias; + this._widgetInfo = widgetInfo; + this._dataHandler = dataHandler; + this._showPinButton = showPinButton; + this._menu = menu || null; + this._isOpen = false; + this.add_style_class_name('bbm-submenu bbm-panel-popupmenuitem'); + + const themeNode = this.peek_theme_node(); + if (themeNode === null) { + this.connectObject('style-changed', () => { + const isStaged = this.get_stage(); + if (isStaged) { + this.disconnectObject(this); + this._finalizeWidget(this.peek_theme_node()); + } + }, this); + } else { + this._finalizeWidget(themeNode); + } + } + + _finalizeWidget(themeNode) { + const colorInfo = { + isDarkMode: this._widgetInfo.isDarkMode, + accentColor: this._widgetInfo.accentColor, + foregroundColor: themeNode.get_foreground_color(), + }; + + this._vBox = new St.BoxLayout({...boxLayoutProps, x_expand: true}); + this.add_child(this._vBox); + + this._hBox = new St.BoxLayout({x_expand: true}); + this._vBox.add_child(this._hBox); + + this._icon = new St.Icon({ + style_class: 'popup-menu-icon bbm-panel-icon', + gicon: this._gIcon(`bbm-${this._dataHandler.getConfig().commonIcon}-symbolic.svg`), + }); + this._hBox.add_child(this._icon); + + this._label = new St.Label({ + text: this._alias, style_class: 'bbm-bt-widget', + y_align: Clutter.ActorAlign.CENTER, + }); + this._hBox.add_child(this._label); + + const expander = new St.Bin({x_expand: true, style: 'min-width: 18px'}); + this._hBox.add_child(expander); + + this._triangle = + new St.Icon({icon_name: 'pan-down-symbolic', style_class: 'bbm-panel-expand-icon'}); + + this._hBox.add_child(this._triangle); + + const headerButtons = {pin: this._showPinButton, btPair: false, collapse: true}; + + this._popupItemBox = new PopupMenuWidgetBox(this._settings, this._gIcon, this._path, + this._alias, this._widgetInfo, colorInfo, headerButtons, this._dataHandler); + + this._popupItemBox.visible = false; + this._vBox.add_child(this._popupItemBox); + + this._updateAccesibleName(); + + if (this._menu) { + this._menu.connectObject('open-state-changed', (o, isOpen) => { + if (!isOpen && this._menu._bbmOpenSubmenu) { + this._menu._bbmOpenSubmenu._toggleSubMenuBox(false, false, false); + this._menu._bbmOpenSubmenu = null; + } + }, this); + } + + this._popupItemBox.collapseButton.connectObject('clicked', () => { + if (this._isOpen) + this._toggleSubMenuBox(false, true, true); + }, this._popupItemBox); + + this.activate = __ => { + if (!this._isOpen) + this._toggleSubMenuBox(true, true, false); + }; + + this.connectObject( + 'enter-event', () => { + this.add_style_pseudo_class('focus'); + }, + 'leave-event', () => { + this.remove_style_pseudo_class('focus'); + }, + this + ); + } + + updatePinButton() { + this._popupItemBox?.updatePinButton(); + } + + updateAlias(alias) { + this._alias = alias; + this._popupItemBox?.updateAlias?.(alias); + this._updateAccesibleName(); + } + + _updateAccesibleName() { + this.accessible_name = `${this._alias} ${_('Expand submenu')}`; + } + + vfunc_key_press_event(event) { + const symbol = event.get_key_symbol(); + if (symbol === Clutter.KEY_space && !this._isOpen) { + this._toggleSubMenuBox(true, true, true); + return Clutter.EVENT_STOP; + } else if (symbol === Clutter.KEY_space && + this._popupItemBox.collapseButton.has_key_focus() && this._isOpen) { + this._toggleSubMenuBox(false, true, true); + return Clutter.EVENT_STOP; + } + return Clutter.EVENT_PROPAGATE; + } + + _toggleSubMenuBox(open, animate, keyGrabCall, doneCallback = null) { + if (open) { + const openNow = () => { + this.active = true; + this.grab_key_focus(); + this.add_style_class_name('open'); + this._hBox.hide(); + this._popupItemBox.opacity = 0; + this._popupItemBox.visible = true; + const [, naturalHeight] = this._popupItemBox.get_preferred_height(-1); + this._popupItemBox.height = 0; + const duration = animate ? 200 : 0; + this.set_can_focus(false); + + this._popupItemBox.ease({ + height: naturalHeight, + opacity: 255, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._popupItemBox.set_height(-1); + if (keyGrabCall) + this._popupItemBox.collapseButton.grab_key_focus(); + if (doneCallback) + doneCallback(); + }, + }); + + this._isOpen = true; + if (this._menu) + this._menu._bbmOpenSubmenu = this; + }; + + if (this._menu && this._menu._bbmOpenSubmenu && this._menu._bbmOpenSubmenu !== this) { + const other = this._menu._bbmOpenSubmenu; + other._toggleSubMenuBox(false, true, false, () => { + openNow(); + }); + return; + } + + openNow(); + return; + } + + if (!this._isOpen) { + if (doneCallback) + doneCallback(); + return; + } + + this.active = false; + this.remove_style_class_name('open'); + + const duration = animate ? 100 : 0; + this._hBox.show(); + + this._popupItemBox.ease({ + height: 0, + opacity: 0, + duration, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => { + this._popupItemBox.visible = false; + this._popupItemBox.set_height(-1); + this._isOpen = false; + this.set_can_focus(true); + if (keyGrabCall) + this.grab_key_focus(); + if (this._menu && this._menu._bbmOpenSubmenu === this) + this._menu._bbmOpenSubmenu = null; + if (doneCallback) + doneCallback(); + }, + }); + } +}); + diff --git a/bbm/lib/widgets/popupMenuWidget.js b/bbm/lib/widgets/popupMenuWidget.js new file mode 100644 index 0000000000..20157abd36 --- /dev/null +++ b/bbm/lib/widgets/popupMenuWidget.js @@ -0,0 +1,291 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {BatterySetWidget} from './batterySetWidget.js'; +import {ToggleButtonsSet} from './toggleButtonsSet.js'; +import {OptionsBox} from './optionsBox.js'; +import {Tooltip} from './tooltip.js'; +import {LabelIndicators} from './labelIndicators.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +export const PopupMenuWidgetBox = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_PopupMenuWidgetBox', +}, class PopupMenuWidgetBox extends St.BoxLayout { + _init(settings, gIcon, path, alias, widgetInfo, colorInfo, headerButtons, dataHandler) { + super._init({...boxLayoutProps, x_expand: true, style_class: 'bbm-popup-menu-box'}); + this._settings = settings; + this._gIcon = gIcon; + this._dataHandler = dataHandler; + this._config = this._dataHandler.getConfig(); + this._showCollapseButton = headerButtons.collapse; + this._showBtPairButton = headerButtons.btPair; + this._showPinButton = headerButtons.pin; + this._showSettingsButton = this._config.showSettingsButton && !widgetInfo.isUnlockSession; + + const showTooltips = settings.get_boolean('enable-tooltip'); + if (showTooltips) + this._tooltip = new Tooltip(this); + + const titleHbox = new St.BoxLayout({style_class: 'bbm-popup-titlebox', x_expand: true}); + this.add_child(titleHbox); + + const modelLabel = new St.Label({ + style_class: 'bbm-popup-title', + y_align: Clutter.ActorAlign.CENTER, + }); + modelLabel.text = alias; + titleHbox.add_child(modelLabel); + + let headerButton; + if (this._showCollapseButton || this._showBtPairButton || + this._showPinButton || this._showSettingsButton) { + const spacer = new St.Bin({x_expand: true, style_class: 'bbm-popupmenu-expander'}); + titleHbox.add_child(spacer); + + headerButton = buttonName => { + const icon = new St.Icon({style_class: 'bbm-header-icon'}); + const button = new St.Button({ + style_class: 'button bbm-header-button', + can_focus: true, + child: icon, + }); + button.icon = icon; + button.accessible_name = buttonName; + this._tooltip?.attach(button, buttonName); + const bin = new St.Bin({ + style_class: 'bbm-header-bin', + y_align: Clutter.ActorAlign.CENTER, + child: button, + }); + button.bin = bin; + return button; + }; + } + + if (this._showPinButton) { + this._pinButton = headerButton(_('Pin to panel')); + titleHbox.add_child(this._pinButton.bin); + this._isSelectedDevice = + this._settings.get_string('default-selected-path') === path; + + this._settings.connectObject('changed::default-selected-path', () => { + this._isSelectedDevice = + this._settings.get_string('default-selected-path') === path; + this.updatePinButton(); + }, this); + + this._pinButton.connectObject('clicked', () => { + if (!this._isSelectedDevice) + this._settings.set_string('default-selected-path', path); + }, this); + + this.updatePinButton(); + } + + if (this._showSettingsButton) { + this._settingsButton = headerButton(_('Device settings')); + titleHbox.add_child(this._settingsButton.bin); + this._settingsButton.icon.gicon = this._gIcon('bbm-settings-symbolic.svg'); + + this._settingsButton.connectObject('clicked', () => { + this._dataHandler.emitUIAction('settingsButtonClicked', 0); + }, this); + } + + if (this._showCollapseButton) { + this.collapseButton = headerButton(_('Collapse submenu')); + this.collapseButton.icon.icon_name = 'pan-up-symbolic'; + titleHbox.add_child(this.collapseButton.bin); + } + + if (this._showBtPairButton) { + this.btPairButton = headerButton(_('Disconnect')); + titleHbox.add_child(this.btPairButton.bin); + } + + const infoHbox = new St.BoxLayout({x_expand: true}); + this.add_child(infoHbox); + + this._modelIcon = new St.Icon({ + style_class: 'bbm-panel-icon', + icon_size: 56, + y_expand: true, + }); + infoHbox.add_child(this._modelIcon); + this._modelIcon.gicon = this._gIcon(`bbm-art-${this._config.albumArtIcon}.png`); + this._batteryBox = new St.BoxLayout({x_expand: true}); + infoHbox.add_child(this._batteryBox); + const startBin = new St.Bin({style_class: 'bbm-panel-start-bin'}); + this._batteryBox.add_child(startBin); + + this._batterySetWidget = new BatterySetWidget(widgetInfo, this._dataHandler); + this._batteryBox.add_child(this._batterySetWidget); + + const button1Enabled = this._config.toggle1Button1Icon && this._config.toggle1Button2Icon; + const button2Enabled = this._config.toggle2Button1Icon && this._config.toggle2Button2Icon; + + if (button1Enabled) { + this._menuSeparator1 = new St.Widget({ + style_class: 'bbm-menu-separator', + x_expand: true, + }); + this.add_child(this._menuSeparator1); + + this._set1ToggleButtons = + new ToggleButtonsSet(this._gIcon, this._tooltip, false, this._dataHandler); + this.add_child(this._set1ToggleButtons); + + this._set1ToggleButtons.bind_property('visible', + this._menuSeparator1, 'visible', + GObject.BindingFlags.SYNC_CREATE); + + const boxes = [ + this._config.optionsBox1, + this._config.optionsBox2, + this._config.optionsBox3, + this._config.optionsBox4, + ]; + + const hasAnyOptions = boxes.some(arr => arr.length > 0); + if (hasAnyOptions) { + const optionBox = new OptionsBox(this._gIcon, colorInfo, this._dataHandler); + this.add_child(optionBox); + this._set1ToggleButtons.bind_property('visible', + optionBox, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + } + + if (button2Enabled) { + this._menuSeparator2 = new St.Widget({ + style_class: 'bbm-menu-separator', + x_expand: true, + }); + + this.add_child(this._menuSeparator2); + + this._set2ToggleButtons = + new ToggleButtonsSet(this._gIcon, this._tooltip, true, this._dataHandler); + + this.add_child(this._set2ToggleButtons); + + this._set2ToggleButtons.bind_property('visible', + this._menuSeparator2, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + if (this._config.labelIndicatorEnabled > 0) { + this._menuSeparator3 = new St.Widget({ + style_class: 'bbm-menu-separator', + x_expand: true, + }); + this.add_child(this._menuSeparator3); + + this._labelIndicator = new LabelIndicators(colorInfo, this._dataHandler); + this.add_child(this._labelIndicator); + + this._labelIndicator.bind_property('visible', + this._menuSeparator3, 'visible', + GObject.BindingFlags.SYNC_CREATE); + } + + this._updateVisibility(); + + this._dataHandler.connectObject( + 'configuration-changed', () => { + this._batterySetWidget.destroy(); + this._batterySetWidget = new BatterySetWidget(widgetInfo, this._dataHandler); + this._batteryBox.add_child(this._batterySetWidget); + const albumArtIcon = this._dataHandler.getConfig().albumArtIcon; + this._modelIcon.gicon = this._gIcon(`bbm-art-${albumArtIcon}.png`); + }, + 'properties-changed', () => { + if (button1Enabled || button2Enabled) + this._updateVisibility(); + }, + this + ); + + this.connectObject('destroy', () => { + this._tooltip?.destroy(); + this._tooltip = null; + }, this); + } + + _updateVisibility() { + const toggle1Visible = this._dataHandler.getProps().toggle1Visible; + const toggle2Visible = this._dataHandler.getProps().toggle2Visible; + + if (toggle1Visible) + this._set1ToggleButtons?.show(); + else + this._set1ToggleButtons?.hide(); + + if (toggle2Visible) + this._set2ToggleButtons?.show(); + else + this._set2ToggleButtons?.hide(); + } + + updateAlias(alias) { + this._modelLabel.text = alias; + } + + updatePinButton() { + if (this._showPinButton && this._pinButton) { + const gioIcon = this._isSelectedDevice ? this._gIcon('bbm-pinned-symbolic.svg') + : this._gIcon('bbm-pin-symbolic.svg'); + + this._pinButton.icon.gicon = gioIcon; + } + } +}); + +export const PopupMenuWidget = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_PopupMenuWidget', +}, class PopupMenuWidget extends PopupMenu.PopupBaseMenuItem { + _init(settings, gIcon, path, alias, widgetInfo, dataHandler) { + super._init({activate: false, hover: false, can_focus: false}); + this.add_style_class_name('bbm-popup-menu'); + this._settings = settings; + this._gIcon = gIcon; + this._path = path; + this._alias = alias; + this._widgetInfo = widgetInfo; + this._dataHandler = dataHandler; + + const themeNode = this.peek_theme_node(); + if (themeNode === null) { + this.connectObject('style-changed', () => { + const node = this.peek_theme_node(); + if (node !== null) { + this.disconnectObject(this); + this._finalizeWidget(node); + } + }, this); + } else { + this._finalizeWidget(themeNode); + } + } + + _finalizeWidget(themeNode) { + const colorInfo = { + accentColor: this._widgetInfo.accentColor, + foregroundColor: themeNode.get_foreground_color(), + }; + + const headerButtons = {pin: false, btPair: false, collapse: false}; + const box = new PopupMenuWidgetBox(this._settings, this._gIcon, this._path, this._alias, + this._widgetInfo, colorInfo, headerButtons, this._dataHandler); + this.add_child(box); + } +}); diff --git a/bbm/lib/widgets/radioButtonBin.js b/bbm/lib/widgets/radioButtonBin.js new file mode 100644 index 0000000000..0e7132a04c --- /dev/null +++ b/bbm/lib/widgets/radioButtonBin.js @@ -0,0 +1,207 @@ +'use strict'; +import Atk from 'gi://Atk'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; + +import {adjustColorLuminanceToRgba, colorToRgba, colorGreyOpacity} from './colorHelpers.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const buttonMask = shellVersion >= 51 ? St.ButtonMask.PRIMARY : St.ButtonMask.ONE; +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +const RadioButton = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_RadioButton', +}, class RadioButton extends St.Bin { + _init(gIcon, styleInfo, length, radioBtnTitle, grpTitle, initialChecked) { + super._init({x_expand: true, style_class: 'button bbm-radiobtn-bin'}); + this._styleInfo = styleInfo; + + const box = new St.BoxLayout({ + ...boxLayoutProps, x_expand: true, + style_class: `bbm-radio${length}btn-box`, + }); + + this.set_child(box); + + this._radioBoxLabel = new St.Label({ + style_class: 'bbm-radiobtn-label', + x_align: Clutter.ActorAlign.FILL, + }); + this._radioBoxLabel.text = radioBtnTitle; + + this.radioButton = new St.Button({ + style_class: 'bbm-radiobtn', + reactive: true, + can_focus: true, + track_hover: true, + button_mask: buttonMask, + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.CENTER, + accessible_role: Atk.Role.RADIO_BUTTON, + accessible_name: `${grpTitle} ${radioBtnTitle}`, + }); + + this._bin = new St.Bin({ + style_class: 'bbm-radiobtn-box', + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.CENTER, + }); + this.radioButton.child = this._bin; + + this._icon = new St.Icon({ + gicon: gIcon('bbm-radio-symbolic.svg'), + style_class: 'bbm-radiobtn-icon', + y_align: Clutter.ActorAlign.CENTER, + x_align: Clutter.ActorAlign.CENTER, + }); + this._bin.set_child(this._icon); + + this.radioButton.checked = initialChecked; + + this.updateStyle(); + this.radioButton.connectObject( + 'clicked', () => { + this.radioButton.checked = !this.radioButton.checked; + this.updateStyle(); + }, + 'notify::hover', () => { + this.updateStyle(); + }, + 'key-focus-in', () => { + this.updateStyle(); + }, + 'key-focus-out', () => { + this.updateStyle(); + }, + this + ); + + box.add_child(this.radioButton); + box.add_child(this._radioBoxLabel); + } + + updateStyle() { + let iconStyle = ''; + let boxStyle = ''; + + if (this.radioButton.checked && this.radioButton.hover) { + iconStyle = this._styleInfo.iconHoverCheckedStyle; + boxStyle = + `${this._styleInfo.borderCheckedStyle} ${ + this._styleInfo.boxHoverCheckedStyle}`; + } else if (this.radioButton.checked) { + iconStyle = this._styleInfo.iconCheckedStyle; + boxStyle = + `${this._styleInfo.borderCheckedStyle} ${ + this._styleInfo.boxCheckedStyle}`; + } else if (this.radioButton.hover) { + iconStyle = this._styleInfo.iconStyle; + boxStyle = + `${this._styleInfo.borderHoverStyle} ${ + this._styleInfo.boxStyle}`; + } else { + iconStyle = this._styleInfo.iconStyle; + boxStyle = + `${this._styleInfo.borderStyle} ${ + this._styleInfo.boxStyle}`; + } + + if (this.radioButton.has_key_focus()) + this.add_style_pseudo_class('focus'); + else + this.remove_style_pseudo_class('focus'); + + + this._icon.set_style(iconStyle); + this._bin.set_style(boxStyle); + } +}); + +export const RadioButtonBin = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_RadioButtonBin', +}, class RadioButtonBin extends St.BoxLayout { + _init(gIcon, colorInfo, dataHandler, id) { + super._init({ + ...boxLayoutProps, x_expand: true, style_class: 'bbm-box', + x_align: Clutter.ActorAlign.FILL, + }); + this._gIcon = gIcon; + this._colorInfo = colorInfo; + this._dataHandler = dataHandler; + this._radioButtons = []; + const config = dataHandler.getConfig(); + const buttons = config[`box${id}RadioButton`]; + + const accentColor = colorInfo.accentColor; + const accentRgba = colorToRgba(accentColor); + const fgColor = colorInfo.foregroundColor; + const fgRgba = colorToRgba(fgColor); + + const hoverAccentColor = adjustColorLuminanceToRgba(accentColor, 5); + const hoverAccentFgColor = adjustColorLuminanceToRgba(fgColor, 5); + + const borderStyle = `border: 2px solid ${colorGreyOpacity(0.50)};`; + const borderHoverStyle = `border: 2px solid ${colorGreyOpacity(0.70)};`; + const borderCheckedStyle = 'border: 2px solid transparent;'; + + const boxStyle = 'background-color: transparent;'; + const boxCheckedStyle = `background-color: ${accentRgba};`; + const boxHoverCheckedStyle = `background-color: ${hoverAccentColor};`; + + const iconStyle = 'color: transparent;'; + const iconCheckedStyle = `color: ${fgRgba};`; + const iconHoverCheckedStyle = `color: ${hoverAccentFgColor};`; + + const styleInfo = { + borderStyle, borderHoverStyle, borderCheckedStyle, + boxStyle, boxCheckedStyle, boxHoverCheckedStyle, + iconStyle, iconCheckedStyle, iconHoverCheckedStyle, + }; + + const title = config[`box${id}RadioTitle`]; + this._radioBoxTitleLabel = new St.Label({ + style_class: 'bbm-subtitle-label', + x_align: Clutter.ActorAlign.FILL, + }); + this._radioBoxTitleLabel.text = title; + this.add_child(this._radioBoxTitleLabel); + + + const hBox = new St.BoxLayout({x_align: Clutter.ActorAlign.FILL}); + + buttons.forEach((label, i) => { + const btnIndex = i + 1; + const state = this._dataHandler.getProps()[`box${id}RadioButtonState`]; + const initialChecked = state === btnIndex; + const btn = + new RadioButton(gIcon, styleInfo, buttons.length, label, title, initialChecked); + this._radioButtons.push(btn); + hBox.add_child(btn); + + btn.radioButton.connectObject('clicked', () => { + this._radioButtons.forEach((b, j) => { + b.radioButton.checked = j === i; + }); + this._radioButtons.forEach(b => b.updateStyle()); + + this._dataHandler.emitUIAction(`box${id}RadioButtonState`, btnIndex); + }, this); + }); + + + this._dataHandler.connectObject('properties-changed', () => { + const state = this._dataHandler.getProps()[`box${id}RadioButtonState`]; + this._radioButtons.forEach((btn, idx) => { + btn.radioButton.checked = state > 0 && idx + 1 === state; + btn.updateStyle(); + }); + }, this); + + this.add_child(hBox); + } +}); + diff --git a/bbm/lib/widgets/sliderBin.js b/bbm/lib/widgets/sliderBin.js new file mode 100644 index 0000000000..a884487ae7 --- /dev/null +++ b/bbm/lib/widgets/sliderBin.js @@ -0,0 +1,86 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; +import {Slider} from 'resource:///org/gnome/shell/ui/slider.js'; + +import {gettext as _} from 'resource:///org/gnome/shell/extensions/extension.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +export const SliderBin = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_SliderBin', +}, class SliderBin extends St.BoxLayout { + _init(gIcon, dataHandler, id) { + super._init({ + style_class: 'bbm-box', + ...boxLayoutProps, x_expand: true, + }); + + const config = dataHandler.getConfig(); + + const sliderLabel = new St.Label({ + x_expand: true, + style_class: 'bbm-subtitle-label', + x_align: Clutter.ActorAlign.CENTER, + }); + const title = config[`box${id}SliderTitle`]; + if (!title) + console.log(`Bluetooth-Battery-Meter: Title for Slider in option-box${id} not defined`); + + sliderLabel.text = title; + this.add_child(sliderLabel); + + const pluslabel = new St.Label({style: 'padding: 4px', x_align: Clutter.ActorAlign.END}); + pluslabel.text = '+'; + const minuslabel = new St.Label({style: 'padding: 4px', x_align: Clutter.ActorAlign.START}); + minuslabel.text = '-'; + + const slider = new Slider(0); + const sliderBin = new St.Bin({ + child: slider, + reactive: true, + can_focus: true, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + style_class: 'slider-bin', + + }); + + const hbox = new St.BoxLayout({style_class: 'quick-slider'}); + hbox.add_child(minuslabel); + hbox.add_child(sliderBin); + hbox.add_child(pluslabel); + this.add_child(hbox); + + const sliderAccessible = slider.get_accessible(); + sliderAccessible.set_parent(sliderBin.get_parent().get_accessible()); + sliderBin.set_accessible(sliderAccessible); + sliderBin.connectObject('event', (bin, event) => slider.event(event, false), this); + slider.accessible_name = sliderLabel.text; + + slider.value = dataHandler.props[`box${id}SliderValue`] / 100; + + slider.connectObject( + 'notify::value', () => { + dataHandler.emitUIAction(`box${id}SliderValue`, slider.value * 100); + }, + 'drag-begin', () => { + dataHandler.emitUIAction(`box${id}SliderIsDragging`, 1); + }, + 'drag-end', () => { + dataHandler.emitUIAction(`box${id}SliderIsDragging`, 0); + }, + this + ); + + dataHandler.connectObject('properties-changed', () => { + slider.value = dataHandler.props[`box${id}SliderValue`] / 100; + }, this); + } +}); + diff --git a/bbm/lib/widgets/toggleButtonsSet.js b/bbm/lib/widgets/toggleButtonsSet.js new file mode 100644 index 0000000000..f43490a78d --- /dev/null +++ b/bbm/lib/widgets/toggleButtonsSet.js @@ -0,0 +1,313 @@ +'use strict'; +import Clutter from 'gi://Clutter'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import * as Config from 'resource:///org/gnome/shell/misc/config.js'; + +const [major] = Config.PACKAGE_VERSION.split('.'); +const shellVersion = Number.parseInt(major); +const boxLayoutProps = shellVersion >= 48 + ? {orientation: Clutter.Orientation.VERTICAL} : {vertical: true}; + +const ToggleButton = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_ToggleButton', +}, class ToggleButton extends St.Button { + _init(gIcon, tooltip, title, iconName, btnName) { + super._init({ + style_class: 'button bbm-toggle-button', + can_focus: true, + accessible_name: `${title} ${btnName}`, + }); + tooltip?.attach(this, btnName); + + const icon = new St.Icon({ + style_class: 'popup-menu-icon', + gicon: gIcon(`${iconName}.svg`), + }); + this.child = icon; + } +}); + +export const ToggleButtonsSet = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_ToggleButtonsSet', +}, class ToggleButtonsSet extends St.BoxLayout { + _init(gIcon, tooltip, isSecondSet, dataHandler) { + super._init({style_class: 'bbm-box', ...boxLayoutProps, x_expand: true}); + + this._gIcon = gIcon; + this._tooltip = tooltip; + this._isSecondSet = isSecondSet; + this._dataHandler = dataHandler; + this._configUpdateGaurd = false; + + const props = this._dataHandler.getProps(); + this._buttonEnabledIndex = this._isSecondSet ? props.toggle2State : props.toggle1State; + this._opacityLow = 'background-color: rgba(128, 128, 128, 0.4);'; + this._opacityNone = 'background-color: transparent;'; + this._updateConfig(); + this._buildWidget(); + this._connectDataHandler(); + } + + _updateConfig() { + const config = this._dataHandler.getConfig(); + this._icon1 = this._isSecondSet ? config.toggle2Button1Icon : config.toggle1Button1Icon; + this._btn1Name = this._isSecondSet ? config.toggle2Button1Name : config.toggle1Button1Name; + this._icon2 = this._isSecondSet ? config.toggle2Button2Icon : config.toggle1Button2Icon; + this._btn2Name = this._isSecondSet ? config.toggle2Button2Name : config.toggle1Button2Name; + this._icon3 = this._isSecondSet ? config.toggle2Button3Icon : config.toggle1Button3Icon; + this._btn3Name = this._isSecondSet ? config.toggle2Button3Name : config.toggle1Button3Name; + this._icon4 = this._isSecondSet ? config.toggle2Button4Icon : config.toggle1Button4Icon; + this._btn4Name = this._isSecondSet ? config.toggle2Button4Name : config.toggle1Button4Name; + this._title = this._isSecondSet ? config.toggle2Title : config.toggle1Title; + } + + _connectDataHandler() { + this._dataHandler.connectObject( + 'configuration-changed', () => { + this._configUpdateGaurd = true; + + this._tooltip?.detach(this._button1); + this._button1?.disconnectObject(this); + this._button1 = null; + this._tooltip?.detach(this._button2); + this._button2?.disconnectObject(this); + this._button2 = null; + this._tooltip?.detach(this._button3); + this._button3?.disconnectObject(this); + this._button3 = null; + this._tooltip?.detach(this._button4); + this._button4?.disconnectObject(this); + this._button4 = null; + this._buttonSeparator1 = null; + this._buttonSeparator2 = null; + this._buttonSeparator3 = null; + + this._hBox?.destroy(); + this._hBox = null; + this._label?.destroy(); + this._label = null; + + this._updateConfig(); + this._buildWidget(); + + this._configUpdateGaurd = false; + + const properties = this._dataHandler.getProps(); + const buttonIndex = this._isSecondSet + ? properties.toggle2State : properties.toggle1State; + this._setActiveButton(buttonIndex); + }, + 'properties-changed', () => { + if (this._configUpdateGaurd) + return; + + const properties = this._dataHandler.getProps(); + const buttonIndex = this._isSecondSet + ? properties.toggle2State : properties.toggle1State; + this._setActiveButton(buttonIndex); + }, + this + ); + } + + _buildWidget() { + if (!this._icon1 || !this._icon2) + return; + + this._label = new St.Label({ + style_class: 'bbm-toggle-label', + x_align: Clutter.ActorAlign.CENTER, + }); + this.add_child(this._label); + this._label.text = this._title; + + this._hBox = new St.BoxLayout({ + x_align: Clutter.ActorAlign.CENTER, + style_class: 'bbm-toggle-box', + }); + this.add_child(this._hBox); + + this._button1 = new ToggleButton(this._gIcon, this._tooltip, this._title, + this._icon1, this._btn1Name); + this._hBox.add_child(this._button1); + + this._buttonSeparator1 = new St.Widget({style_class: 'bbm-toggle-button-separator'}); + this._hBox.add_child(this._buttonSeparator1); + + this._button2 = new ToggleButton(this._gIcon, this._tooltip, this._title, + this._icon2, this._btn2Name); + this._hBox.add_child(this._button2); + + if (this._icon3) { + this._buttonSeparator2 = new St.Widget({style_class: 'bbm-toggle-button-separator'}); + this._hBox.add_child(this._buttonSeparator2); + + this._button3 = new ToggleButton(this._gIcon, this._tooltip, this._title, + this._icon3, this._btn3Name); + this._hBox.add_child(this._button3); + } + + if (this._icon4) { + this._buttonSeparator3 = new St.Widget({style_class: 'bbm-toggle-button-separator'}); + this._hBox.add_child(this._buttonSeparator3); + + this._button4 = new ToggleButton(this._gIcon, this._tooltip, this._title, + this._icon4, this._btn4Name); + this._hBox.add_child(this._button4); + } + + this._button1.connectObject( + 'clicked', () => { + const buttonNumber = 1; + const toggleState = this._isSecondSet ? 'toggle2State' : 'toggle1State'; + this._dataHandler.emitUIAction(toggleState, buttonNumber); + }, + 'notify::hover', () => { + this._updateSeperator1(); + }, + 'notify::checked', () => { + this._updateSeperator1(); + }, + 'key-focus-in', () => { + this._updateSeperator1(); + }, + 'key-focus-out', () => { + this._updateSeperator1(); + }, + this + ); + + this._button2.connectObject( + 'clicked', () => { + const buttonNumber = 2; + const toggleState = this._isSecondSet ? 'toggle2State' : 'toggle1State'; + this._dataHandler.emitUIAction(toggleState, buttonNumber); + }, + 'notify::hover', () => { + this._updateSeperator1(); + if (this._icon3) + this._updateSeperator2(); + }, + 'notify::checked', () => { + this._updateSeperator1(); + if (this._icon3) + this._updateSeperator2(); + }, + 'key-focus-in', () => { + this._updateSeperator1(); + if (this._icon3) + this._updateSeperator2(); + }, + 'key-focus-out', () => { + this._updateSeperator1(); + if (this._icon3) + this._updateSeperator2(); + }, + this + ); + + + if (this._icon3) { + this._button3.connectObject( + 'clicked', () => { + const buttonNumber = 3; + const toggleState = this._isSecondSet ? 'toggle2State' : 'toggle1State'; + this._dataHandler.emitUIAction(toggleState, buttonNumber); + }, + 'notify::hover', () => { + this._updateSeperator2(); + if (this._icon4) + this._updateSeperator3(); + }, + 'notify::checked', () => { + this._updateSeperator2(); + if (this._icon4) + this._updateSeperator3(); + }, + 'key-focus-in', () => { + this._updateSeperator2(); + if (this._icon4) + this._updateSeperator3(); + }, + 'key-focus-out', () => { + this._updateSeperator2(); + if (this._icon4) + this._updateSeperator3(); + }, + this + ); + } + + if (this._icon4) { + this._button4.connectObject( + 'clicked', () => { + const buttonNumber = 4; + const toggleState = this._isSecondSet ? 'toggle2State' : 'toggle1State'; + this._dataHandler.emitUIAction(toggleState, buttonNumber); + }, + 'notify::hover', () => { + this._updateSeperator3(); + }, + 'notify::checked', () => { + this._updateSeperator3(); + }, + 'key-focus-in', () => { + this._updateSeperator3(); + }, + 'key-focus-out', () => { + this._updateSeperator3(); + }, + this + ); + } + + this._buttonSeparator1.set_style(this._opacityLow); + this._buttonSeparator2?.set_style(this._opacityLow); + this._buttonSeparator3?.set_style(this._opacityLow); + + this._setActiveButton(this._buttonEnabledIndex); + } + + _updateSeperator1() { + if (this._button1.checked || this._button2.checked) + this._buttonSeparator1.set_style(this._opacityNone); + else if (this._button1.hover || this._button2.hover) + this._buttonSeparator1.set_style(this._opacityNone); + else if (this._button1.has_key_focus() || this._button2.has_key_focus()) + this._buttonSeparator1.set_style(this._opacityNone); + else + this._buttonSeparator1.set_style(this._opacityLow); + } + + _updateSeperator2() { + if (this._button2.checked || this._button3.checked) + this._buttonSeparator2.set_style(this._opacityNone); + else if (this._button2.hover || this._button3.hover) + this._buttonSeparator2.set_style(this._opacityNone); + else if (this._button2.has_key_focus() || this._button3.has_key_focus()) + this._buttonSeparator3.set_style(this._opacityNone); + else + this._buttonSeparator2.set_style(this._opacityLow); + } + + _updateSeperator3() { + if (this._button3.checked || this._button4.checked) + this._buttonSeparator3.set_style(this._opacityNone); + else if (this._button3.hover || this._button4.hover) + this._buttonSeparator3.set_style(this._opacityNone); + else if (this._button3.has_key_focus() || this._button4.has_key_focus()) + this._buttonSeparator3.set_style(this._opacityNone); + else + this._buttonSeparator3.set_style(this._opacityLow); + } + + _setActiveButton(buttonNumber) { + const buttons = [this._button1, this._button2, this._button3, this._button4]; + + buttons.forEach((button, index) => { + button?.set_checked(buttonNumber !== 0 && index === buttonNumber - 1); + }); + } +}); + diff --git a/bbm/lib/widgets/tooltip.js b/bbm/lib/widgets/tooltip.js new file mode 100644 index 0000000000..ec364c5a8d --- /dev/null +++ b/bbm/lib/widgets/tooltip.js @@ -0,0 +1,186 @@ +import GLib from 'gi://GLib'; +import GObject from 'gi://GObject'; +import St from 'gi://St'; +import Clutter from 'gi://Clutter'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +export const Tooltip = GObject.registerClass({ + GTypeName: 'BluetoothBatteryMeter_Tooltip', +}, class Tooltip extends GObject.Object { + _init(parentWidget) { + super._init(); + + this._parentWidget = parentWidget; + this._attachMap = new Map(); + this._tooltipActor = new St.Widget({reactive: false, visible: true}); + this._tooltipLabel = new St.Label({ + style_class: 'screenshot-ui-tooltip bbm-tooltip', + visible: false, + reactive: false, + }); + this._tooltipActor.add_child(this._tooltipLabel); + + this._widget = null; + this._timeoutShowId = null; + this._addedToUiGroup = false; + + this._parentWidget.connectObject( + 'notify::mapped', () => { + if (!this._parentWidget.mapped) { + this._hide(true); + if (this._addedToUiGroup) { + Main.uiGroup.remove_child(this._tooltipActor); + this._addedToUiGroup = false; + } + } + }, + this + ); + } + + attach(widget, text) { + if (!widget || !text) + return; + + if (this._attachMap.has(widget)) { + const handlerId = this._attachMap.get(widget); + widget.disconnect(handlerId); + this._attachMap.delete(widget); + } + + const handlerId = widget.connect('notify::hover', () => { + if (widget.hover) + this._scheduleShow(widget, text); + else + this._hide(); + }); + + this._attachMap.set(widget, handlerId); + } + + detach(widget) { + if (!widget) + return; + + const handlerId = this._attachMap.get(widget); + if (handlerId) { + widget.disconnect(handlerId); + this._attachMap.delete(widget); + } + } + + _scheduleShow(widget, text) { + if (this._timeoutShowId) + GLib.source_remove(this._timeoutShowId); + + this._timeoutShowId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 800, () => { + if (!this._addedToUiGroup && this._parentWidget.visible) { + Main.uiGroup.add_child(this._tooltipActor); + this._addedToUiGroup = true; + } + this._show(widget, text); + this._timeoutShowId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _show(widget, text) { + if (!this._parentWidget.visible) + return; + + this._tooltipLabel.set_text(text); + this._widget = widget; + + if (!this._widget) + return; + + const [x, y] = this._widget.get_transformed_position(); + const [w, h] = this._widget.get_transformed_size(); + const tw = this._tooltipLabel.width; + const th = this._tooltipLabel.height; + + if (x === null || y === null || w === null || h === null) + return; + + this._tooltipActor.set_position( + Math.round(x + w / 2 - tw / 2), + Math.round(y - th + 55) + ); + + this._tooltipLabel.opacity = 0; + this._tooltipLabel.show(); + this._tooltipLabel.remove_all_transitions(); + this._tooltipLabel.ease({ + opacity: 200, + duration: 150, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + + if (this._timeoutHideId) + GLib.source_remove(this._timeoutHideId); + + this._timeoutHideId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => { + this._hide(); + + this._timeoutHideId = null; + return GLib.SOURCE_REMOVE; + }); + } + + _hide(immediate = false) { + if (this._timeoutShowId) + GLib.source_remove(this._timeoutShowId); + this._timeoutShowId = null; + + + if (this._timeoutHideId) + GLib.source_remove(this._timeoutHideId); + this._timeoutHideId = null; + + + if (!this._tooltipLabel.visible) + return; + + this._tooltipLabel.remove_all_transitions(); + + if (immediate) { + this._tooltipLabel.hide(); + this._tooltipLabel.opacity = 0; + return; + } + + this._tooltipLabel.ease({ + opacity: 0, + duration: 100, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + onComplete: () => this._tooltipLabel.hide(), + }); + + this._widget = null; + } + + destroy() { + if (this._timeoutShowId) + GLib.source_remove(this._timeoutShowId); + this._timeoutShowId = null; + + if (this._timeoutHideId) + GLib.source_remove(this._timeoutHideId); + this._timeoutHideId = null; + + if (this._addedToUiGroup) { + Main.uiGroup.remove_child(this._tooltipActor); + this._addedToUiGroup = false; + } + + for (const [widget, handlerId] of this._attachMap) + widget.disconnect(handlerId); + + this._attachMap.clear(); + + this._tooltipActor?.destroy(); + this._tooltipActor = null; + this._tooltipLabel = null; + } +}); + diff --git a/flake.nix b/flake.nix index 7058c6cc4d..30a76e5783 100644 --- a/flake.nix +++ b/flake.nix @@ -44,6 +44,7 @@ }; caelestia-cli = inputs.caelestia-cli.packages.${pkgs.stdenv.hostPlatform.system}.default; }; + bbm-daemon = pkgs.callPackage ./nix/bbm.nix {}; with-cli = caelestia-shell.override {withCli = true;}; debug = caelestia-shell.override {debug = true;}; default = caelestia-shell; diff --git a/modules/ServiceLoader.qml b/modules/ServiceLoader.qml index df8adaa954..374c1e8cbd 100644 --- a/modules/ServiceLoader.qml +++ b/modules/ServiceLoader.qml @@ -16,5 +16,7 @@ Scope { if (GlobalConfig.utilities.vpn.enabled) VPN; + + BbmService; } } diff --git a/modules/bar/Bar.qml b/modules/bar/Bar.qml index f12f713767..d25d4c6655 100644 --- a/modules/bar/Bar.qml +++ b/modules/bar/Bar.qml @@ -31,7 +31,10 @@ ColumnLayout { } function checkPopout(y: real): void { - const ch = childAt(width / 2, y) as EntryWrapper; + let ch = childAt(width / 2, y) as EntryWrapper; + while (ch && !ch.entryId && ch.parent && ch !== root) { + ch = ch.parent as EntryWrapper; + } if (ch?.entryId !== "tray") closeTray(); @@ -46,10 +49,33 @@ ColumnLayout { if (id === "statusIcons" && Config.bar.popouts.statusIcons) { const items = (ch.item as StatusIcons).items; - const icon = items.childAt(items.width / 2, mapToItem(items, 0, y).y); + const localY = mapToItem(items, 0, y).y; + let icon = null; + + for (let i = 0; i < items.children.length; ++i) { + const child = items.children[i]; + if (!child || !child.visible) + continue; + const top = child.mapToItem(items, 0, 0).y; + const bottom = top + (child.implicitHeight || child.height || 0); + if (localY >= top && localY <= bottom) { + icon = child; + break; + } + } + if (icon) { - popouts.currentName = icon.name; - popouts.currentCenter = Qt.binding(() => icon.mapToItem(root, 0, icon.implicitHeight / 2).y); + let hit = icon; + while (hit && !hit.name && hit.parent && hit !== items) { + hit = hit.parent; + } + const hitName = hit?.name ?? icon?.name ?? ""; + if (hitName === "bluetooth" && GlobalConfig.services && !GlobalConfig.services.bluetoothBatteryHoverMode) { + popouts.hasCurrent = false; + return; + } + popouts.currentName = hitName || "bluetooth"; + popouts.currentCenter = Qt.binding(() => (hit ?? icon).mapToItem(root, 0, (hit ?? icon).implicitHeight / 2).y); popouts.hasCurrent = true; } } else if (id === "tray" && Config.bar.popouts.tray) { diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index 90ad09b5d5..c3f5fd0aa4 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -129,7 +129,10 @@ StyledRect { DelegateChoice { roleValue: "bluetooth" delegate: EntryWrapper { + name: "bluetooth" + BluetoothStatus { + name: "bluetooth" colour: root.colour } } diff --git a/modules/bar/components/status/BluetoothStatus.qml b/modules/bar/components/status/BluetoothStatus.qml index 300ebdf86c..cd19a73721 100644 --- a/modules/bar/components/status/BluetoothStatus.qml +++ b/modules/bar/components/status/BluetoothStatus.qml @@ -6,13 +6,50 @@ import Quickshell import Quickshell.Bluetooth import Caelestia.Config import qs.components +import qs.components.controls +import qs.services import qs.utils Item { id: root + property string name: "bluetooth" required property color colour + readonly property bool taskbarEnabled: { + const values = Config.bar?.statusIcons?.values ?? []; + return values.some(item => item?.id === "bluetooth" && item.enabled !== false); + } + readonly property bool showBatteryRing: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryRing) + + readonly property var connectedPhoneBattery: { + const devices = Bluetooth.devices.values ?? []; // qmllint disable unresolved-type + for (const device of devices) { + if (!device?.connected) + continue; + const icon = (device.icon ?? "").toLowerCase(); + const isPhone = icon.includes("phone") || icon.includes("android") || icon.includes("smartphone"); + if (!isPhone) + continue; + if (device.batteryAvailable !== undefined && device.batteryAvailable !== null) { + const pct = Number(device.battery) * 100; + if (Number.isFinite(pct)) + return { + percentage: pct, + available: true + }; + } + const bbmEntry = BbmService.available ? BbmService.dataFor(device.address ?? "") : null; + const bbmLevel = Number(bbmEntry?.ComputedLevel ?? NaN); + if (Number.isFinite(bbmLevel)) + return { + percentage: bbmLevel, + available: true + }; + } + return null; + } + visible: root.taskbarEnabled implicitWidth: layout.implicitWidth implicitHeight: layout.implicitHeight @@ -22,12 +59,11 @@ Item { } } - ColumnLayout { + RowLayout { id: layout - spacing: Tokens.spacing.medium / 2 + spacing: Tokens.spacing.extraSmall - // Bluetooth icon MaterialIcon { animate: true text: { @@ -40,41 +76,32 @@ Item { color: root.colour } - // Connected bluetooth devices - Repeater { - model: ScriptModel { - values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected) // qmllint disable unresolved-type + MaterialIcon { + visible: !!root.connectedPhoneBattery + animate: true + text: { + const pct = Number(root.connectedPhoneBattery?.percentage ?? 0); + if (pct >= 90) + return "battery_6_bar"; + if (pct >= 70) + return "battery_5_bar"; + if (pct >= 50) + return "battery_4_bar"; + if (pct >= 30) + return "battery_3_bar"; + if (pct >= 15) + return "battery_2_bar"; + return "battery_1_bar"; } + color: root.colour + fontStyle: Tokens.font.icon.small + } - MaterialIcon { - id: device - - required property BluetoothDevice modelData - - animate: true - text: Icons.getBluetoothIcon(modelData?.icon) - color: root.colour - fill: 1 - - SequentialAnimation on opacity { - running: device.modelData?.state !== BluetoothDeviceState.Connected // qmllint disable unresolved-type - alwaysRunToEnd: true - loops: Animation.Infinite - - Anim { - from: 1 - to: 0 - duration: Tokens.anim.durations.large - easing: Tokens.anim.standardAccel - } - Anim { - from: 0 - to: 1 - duration: Tokens.anim.durations.large - easing: Tokens.anim.standardDecel - } - } - } + StyledText { + visible: !!root.connectedPhoneBattery + text: `${Math.round(root.connectedPhoneBattery?.percentage ?? 0)}%` + color: root.colour + font: Tokens.font.body.small } } } diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml new file mode 100644 index 0000000000..006dce3f43 --- /dev/null +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -0,0 +1,287 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Caelestia +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services + +StyledRect { + id: root + + required property var bbmData + required property string address + + function batteryColorForLevel(levelPercent) { + const level = Math.max(0, Math.min(100, Number(levelPercent) || 0)); + const error = Colours.palette.m3error; + const tertiary = Colours.palette.m3tertiary; + const primary = Colours.palette.m3primary; + function lerp(a, b, t) { + return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, 1); + } + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); + return lerp(tertiary, primary, (level - 40) / 60); + } + + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.extraSmall + + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + clip: true + implicitHeight: content.implicitHeight + Tokens.padding.large * 2 + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: content + + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: Tokens.padding.large + } + spacing: Tokens.spacing.small + + // ── Circular per-component battery rings ────────────────────────────── + + Item { + visible: (root.bbmData?.Battery1Level ?? 0) > 0 + Layout.fillWidth: true + implicitHeight: batteryRow.implicitHeight + + Row { + id: batteryRow + + anchors.horizontalCenter: parent.horizontalCenter + spacing: Tokens.spacing.large + + Repeater { + model: BbmService.batteriesFor(root.bbmData) + + delegate: Column { + id: circleDel + + required property var modelData + + spacing: Tokens.spacing.extraSmall + + Item { + implicitWidth: 56 + implicitHeight: 56 + + CircularProgress { + anchors.fill: parent + value: circleDel.modelData.level / 100 + strokeWidth: 4 + fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) + bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } + + BbmIcon { + anchors.centerIn: parent + url: BbmService.batteryIconUrl(circleDel.modelData.icon) + visible: url.length > 0 + size: 22 + colour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) + } + + // Charging bolt badge at bottom of ring + MaterialIcon { + visible: circleDel.modelData.charging + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + text: "bolt" + color: Colours.palette.m3tertiary + fontStyle: Tokens.font.icon.small + } + } + + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: `${circleDel.modelData.level}%` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } + } + } + } + + // ── ANC / mode toggle (icon-only segmented control) ─────────────────── + + Repeater { + model: BbmService.togglesFor(root.bbmData) + + delegate: ColumnLayout { + id: toggleRow + + required property var modelData + + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + StyledText { + visible: toggleRow.modelData.title.length > 0 + text: toggleRow.modelData.title + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + // Segmented row — icon-only pills with medium rounding + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + Repeater { + model: toggleRow.modelData.buttons.map((name, i) => ({ + name, + icon: (toggleRow.modelData.buttonIcons ?? [])[i] ?? "", + active: toggleRow.modelData.state === (i + 1), + widgetId: toggleRow.modelData.widgetId, + index: i + })) + + delegate: StyledRect { + id: pill + + required property var modelData + + implicitHeight: pillIcon.size + Tokens.padding.small * 2 + implicitWidth: pillIcon.size + Tokens.padding.medium * 2 + + radius: Tokens.rounding.medium + color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + Behavior on color { + Anim { + type: Anim.DefaultEffects + } + } + + StateLayer { + radius: Tokens.rounding.medium + color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(root.address, pill.modelData.widgetId, pill.modelData.index + 1) + } + + BbmIcon { + id: pillIcon + + anchors.centerIn: parent + url: BbmService.toggleIconUrl(pill.modelData.icon) + visible: url.length > 0 + size: 18 + colour: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + } + } + } + } + } + + // ── NC intensity levels — pops in only when NC mode is active ───────── + + Item { + id: ancLevelContainer + + readonly property bool showing: (root.bbmData?.OptionsBoxVisible ?? 0) === 1 && (root.bbmData?.AncLevelButtons ?? []).length > 0 + + Layout.fillWidth: true + implicitHeight: showing ? ancLevelLayout.implicitHeight : 0 + clip: true + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: ancLevelLayout + + width: parent.width + spacing: Tokens.spacing.extraSmall + + opacity: ancLevelContainer.showing ? 1 : 0 + scale: ancLevelContainer.showing ? 1 : 0.92 + transformOrigin: Item.Top + + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + + Behavior on scale { + Anim { + type: Anim.StandardSmall + } + } + + StyledText { + visible: (root.bbmData?.AncLevelTitle ?? "").length > 0 + text: root.bbmData?.AncLevelTitle ?? "" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + Repeater { + model: (root.bbmData?.AncLevelButtons ?? []).map((name, i) => ({ + name, + active: (root.bbmData?.AncLevelState ?? 0) === (i + 1), + index: i + })) + + delegate: StyledRect { + id: levelPill + + required property var modelData + + implicitHeight: levelLabel.implicitHeight + Tokens.padding.small * 2 + implicitWidth: levelLabel.implicitWidth + Tokens.padding.medium * 2 + + radius: Tokens.rounding.medium + color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + Behavior on color { + Anim { + type: Anim.DefaultEffects + } + } + + StateLayer { + radius: Tokens.rounding.medium + color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(root.address, BbmService.WIDGET_ANC_LEVEL, levelPill.modelData.index + 1) + } + + StyledText { + id: levelLabel + + anchors.centerIn: parent + text: levelPill.modelData.name + color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + font: Tokens.font.body.small + } + } + } + } + } + } + } +} diff --git a/modules/bar/popouts/BbmIcon.qml b/modules/bar/popouts/BbmIcon.qml new file mode 100644 index 0000000000..582d1530ec --- /dev/null +++ b/modules/bar/popouts/BbmIcon.qml @@ -0,0 +1,47 @@ +import QtQuick +import qs.components.effects + +Item { + id: iconItem + + required property string url + required property color colour + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + id: img + + anchors.fill: parent + source: iconItem.url + sourceSize: Qt.size(iconItem.size, iconItem.size) + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + visible: status !== Image.Error + + layer.enabled: true + layer.effect: Colouriser { + sourceColor: analyser.dominantColour + colorizationColor: iconItem.colour + } + + layer.onEnabledChanged: { + if (layer.enabled && img.status === Image.Ready) + analyser.requestUpdate(); + } + + onStatusChanged: { + if (layer.enabled && img.status === Image.Ready) + analyser.requestUpdate(); + } + } + + ImageAnalyser { + id: analyser + + sourceItem: img + } +} diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index ebf21de30e..0acc1cfed6 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -7,6 +7,7 @@ import Quickshell.Bluetooth import Caelestia.Config import qs.components import qs.components.controls +import qs.components.effects import qs.services import qs.utils @@ -14,12 +15,36 @@ ColumnLayout { id: root required property PopoutState popouts + readonly property int indicatorType: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryIndicatorType : undefined; + return Number.isFinite(mode) ? mode : 2; + } + readonly property bool showBatteryIcon: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryIcon) + readonly property bool showBatteryText: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryText) + readonly property bool showBatteryRing: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryRing) + readonly property bool swapIconText: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatterySwapIconText) + + function batteryColorForLevel(levelPercent) { + const level = Math.max(0, Math.min(100, Number(levelPercent) || 0)); + const error = Colours.palette.m3error; + const tertiary = Colours.palette.m3tertiary; + const primary = Colours.palette.m3primary; + function lerp(a, b, t) { + return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, 1); + } + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); + return lerp(tertiary, primary, (level - 40) / 60); + } - width: 300 + width: 320 spacing: Tokens.spacing.small StyledText { Layout.topMargin: Tokens.padding.medium + Layout.leftMargin: Tokens.padding.extraSmall Layout.rightMargin: Tokens.padding.extraSmall text: qsTr("Bluetooth") font: Tokens.font.body.builders.medium.weight(Font.Medium).build() @@ -47,6 +72,7 @@ ColumnLayout { StyledText { Layout.topMargin: Tokens.spacing.small + Layout.leftMargin: Tokens.padding.extraSmall Layout.rightMargin: Tokens.padding.extraSmall text: { const devices = Bluetooth.devices.values; // qmllint disable unresolved-type @@ -65,15 +91,23 @@ ColumnLayout { values: [...Bluetooth.devices.values].sort((a, b) => (b.connected - a.connected) || (b.paired - a.paired) || a.name.localeCompare(b.name)).slice(0, 5) // qmllint disable unresolved-type } - RowLayout { + ColumnLayout { id: device required property BluetoothDevice modelData readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type + property bool expanded: false + // Always expandable when connected — shows battery rings (if available) + icon picker + readonly property bool canExpand: modelData.connected + readonly property bool hasBatteryData: root.showBatteryRing && ((BbmService.available && BbmService.dataFor(modelData.address) !== null) || modelData.batteryAvailable || (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport)) + + onCanExpandChanged: if (!canExpand) + expanded = false Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.extraSmall Layout.rightMargin: Tokens.padding.extraSmall - spacing: Tokens.spacing.small + spacing: 0 opacity: 0 scale: 0.7 @@ -93,79 +127,174 @@ ColumnLayout { Anim {} } - MaterialIcon { - text: Icons.getBluetoothIcon(device.modelData.icon) - } - - StyledText { - Layout.leftMargin: Tokens.spacing.extraSmall - Layout.rightMargin: Tokens.spacing.extraSmall + RowLayout { Layout.fillWidth: true - text: device.modelData.name - elide: Text.ElideRight - } + spacing: Tokens.spacing.small - MaterialIcon { - visible: device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type - text: device.modelData.batteryAvailable ? Icons.getBatteryIcon(device.modelData.battery) : "battery_alert" - color: device.modelData.batteryAvailable && device.modelData.battery < 0.2 ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant - } + // Device type icon (BBM SVG with Material fallback) + Item { + id: deviceListIconItem - StyledRect { - id: connectBtn + readonly property string bbmUrl: BbmService.deviceIconUrl(device.modelData.address, device.modelData.icon ?? "") - implicitWidth: implicitHeight - implicitHeight: connectIcon.implicitHeight + Tokens.padding.extraSmall + implicitWidth: deviceListMat.implicitWidth + implicitHeight: deviceListMat.implicitHeight - radius: Tokens.rounding.full - color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) // qmllint disable unresolved-type + ColouredIcon { + anchors.centerIn: parent + source: deviceListIconItem.bbmUrl + visible: deviceListIconItem.bbmUrl.length > 0 + width: deviceListMat.implicitWidth + height: deviceListMat.implicitHeight + colour: Colours.palette.m3onSurface + } - CircularIndicator { - anchors.fill: parent - running: device.loading - } + MaterialIcon { + id: deviceListMat - StateLayer { - color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type - disabled: device.loading - onClicked: device.modelData.connected = !device.modelData.connected + anchors.centerIn: parent + visible: deviceListIconItem.bbmUrl.length === 0 + // "bluetooth" is used as a size reference when the BBM icon is shown; + // avoids BBM names like "earbuds-stem" inflating implicitWidth via the font + text: deviceListIconItem.bbmUrl.length > 0 ? "bluetooth" : Icons.getDeviceIcon(device.modelData.address, device.modelData.icon ?? "") + } } MaterialIcon { - id: connectIcon + visible: root.showBatteryIcon && root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + text: device.modelData.batteryAvailable ? Icons.getBatteryIcon(device.modelData.battery) : "battery_alert" + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error + fontStyle: Tokens.font.icon.small + } + + StyledText { + visible: root.showBatteryText && root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + text: device.modelData.batteryAvailable ? Math.round(device.modelData.battery * 100) + "%" : "--%" + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error + font: Tokens.font.body.small + } + + Item { + Layout.fillWidth: true + implicitHeight: deviceName.implicitHeight + Tokens.padding.extraSmall - anchors.centerIn: parent - animate: true - text: device.modelData.connected ? "link_off" : "link" - color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type + StateLayer { + enabled: device.canExpand + radius: Tokens.rounding.small + onClicked: device.expanded = !device.expanded + } - opacity: device.loading ? 0 : 1 + StyledText { + id: deviceName - Behavior on opacity { - Anim { - type: Anim.DefaultEffects + anchors { + left: parent.left + right: parent.right + verticalCenter: parent.verticalCenter + leftMargin: Tokens.spacing.extraSmall + rightMargin: Tokens.spacing.extraSmall } + text: device.modelData.name + elide: Text.ElideRight } } - } - Loader { - visible: status === Loader.Ready - asynchronous: true - active: device.modelData.bonded - sourceComponent: Item { - implicitWidth: connectBtn.implicitWidth - implicitHeight: connectBtn.implicitHeight + StyledText { + visible: root.showBatteryText && !root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + text: device.modelData.batteryAvailable ? Math.round(device.modelData.battery * 100) + "%" : "--%" + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error + font: Tokens.font.body.small + } + + MaterialIcon { + visible: root.showBatteryIcon && !root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + text: device.modelData.batteryAvailable ? Icons.getBatteryIcon(device.modelData.battery) : "battery_alert" + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error + fontStyle: Tokens.font.icon.small + } + + // Connect / disconnect button + StyledRect { + id: connectBtn + + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Tokens.padding.extraSmall + + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) // qmllint disable unresolved-type + + CircularIndicator { + anchors.fill: parent + running: device.loading + } StateLayer { - radius: Tokens.rounding.full - onClicked: device.modelData.forget() + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type + disabled: device.loading + onClicked: device.modelData.connected = !device.modelData.connected } MaterialIcon { + id: connectIcon + anchors.centerIn: parent - text: "delete" + animate: true + text: device.modelData.connected ? "link_off" : "link" + color: device.modelData.state === BluetoothDeviceState.Connected ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface // qmllint disable unresolved-type + + opacity: device.loading ? 0 : 1 + + Behavior on opacity { + Anim { + type: Anim.DefaultEffects + } + } + } + } + + // Forget button + Loader { + visible: status === Loader.Ready + asynchronous: true + active: device.modelData.bonded + sourceComponent: Item { + implicitWidth: connectBtn.implicitWidth + implicitHeight: connectBtn.implicitHeight + + StateLayer { + radius: Tokens.rounding.full + onClicked: device.modelData.forget() + } + + MaterialIcon { + anchors.centerIn: parent + text: "delete" + } + } + } + } + + Loader { + visible: status === Loader.Ready + active: device.expanded && device.hasBatteryData + Layout.fillWidth: true + sourceComponent: BbmExpandedRow { + bbmData: { + const bbm = BbmService.available ? BbmService.dataFor(device.modelData.address) : null; + if (bbm) + return bbm; + if (device.modelData.batteryAvailable) { + return { + Battery1Level: Math.round(device.modelData.battery * 100), + Battery1Icon: "", + Battery1Status: "discharging", + Battery2Level: 0, + Battery3Level: 0 + }; + } + return null; } + address: device.modelData.address } } } @@ -189,6 +318,7 @@ ColumnLayout { property alias toggle: toggle Layout.fillWidth: true + Layout.leftMargin: Tokens.padding.extraSmall Layout.rightMargin: Tokens.padding.extraSmall spacing: Tokens.spacing.medium diff --git a/modules/bar/popouts/BtDeviceHover.qml b/modules/bar/popouts/BtDeviceHover.qml new file mode 100644 index 0000000000..915796ae78 --- /dev/null +++ b/modules/bar/popouts/BtDeviceHover.qml @@ -0,0 +1,365 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.utils + +StyledRect { + id: root + + required property PopoutState popouts + + readonly property var connectedDevices: (Bluetooth.devices.values ?? []).filter( // qmllint disable unresolved-type + d => d.state !== BluetoothDeviceState.Disconnected) // qmllint disable unresolved-type + + function batteryColorForLevel(levelPercent) { + const level = Math.max(0, Math.min(100, Number(levelPercent) || 0)); + const error = Colours.palette.m3error; + const tertiary = Colours.palette.m3tertiary; + const primary = Colours.palette.m3primary; + function lerp(a, b, t) { + return Qt.rgba(a.r + (b.r - a.r) * t, a.g + (b.g - a.g) * t, a.b + (b.b - a.b) * t, 1); + } + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); + return lerp(tertiary, primary, (level - 40) / 60); + } + + width: 340 + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + clip: true + implicitHeight: devicesColumn.implicitHeight + Tokens.padding.large * 2 + + ColumnLayout { + id: devicesColumn + + anchors { + top: parent.top + left: parent.left + right: parent.right + margins: Tokens.padding.large + } + spacing: Tokens.spacing.medium + + Repeater { + model: ScriptModel { + values: root.connectedDevices + } + + // ── Per-device section ────────────────────────────────────────────── + delegate: ColumnLayout { + id: deviceSection + + required property BluetoothDevice modelData // qmllint disable unresolved-type + + readonly property string address: deviceSection.modelData?.address ?? "" + readonly property var bbmData: BbmService.available ? BbmService.dataFor(deviceSection.address) : null + + Layout.fillWidth: true + spacing: Tokens.spacing.small + + // divider between devices + Rectangle { + visible: deviceSection.index > 0 + Layout.fillWidth: true + height: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } + + // ── Device name ─────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + BbmIcon { + readonly property string url_: BbmService.deviceIconUrl(deviceSection.address, deviceSection.modelData?.icon ?? "") + + url: url_ + visible: url_.length > 0 + size: 16 + colour: Colours.palette.m3onSurfaceVariant + } + + StyledText { + Layout.fillWidth: true + text: deviceSection.modelData?.name ?? "" + font: Tokens.font.body.small + elide: Text.ElideRight + } + } + + // ── Battery circles ─────────────────────────────────────────── + Item { + Layout.fillWidth: true + visible: GlobalConfig.services?.bluetoothHoverShowBatteries ?? true + implicitHeight: batteryRow.implicitHeight + + Row { + id: batteryRow + + anchors.horizontalCenter: parent.horizontalCenter + spacing: Tokens.spacing.medium + + Repeater { + model: ScriptModel { + values: deviceSection.bbmData ? BbmService.batteriesFor(deviceSection.bbmData) : deviceSection.modelData?.batteryAvailable === true ? [ + { + icon: "", + level: Math.round((deviceSection.modelData?.battery ?? 0) * 100), + charging: false + } + ] : [] + } + + ColumnLayout { + id: circleDel + + required property var modelData + + spacing: Tokens.spacing.extraSmall + + Item { + implicitWidth: 56 + implicitHeight: 56 + + CircularProgress { + anchors.fill: parent + value: circleDel.modelData.level / 100 + strokeWidth: 4 + fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) + bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } + + BbmIcon { + anchors.centerIn: parent + url: BbmService.batteryIconUrl(circleDel.modelData.icon) + visible: url.length > 0 + size: 22 + colour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) + } + + MaterialIcon { + visible: (circleDel.modelData.icon ?? "").length === 0 + anchors.centerIn: parent + text: Icons.getDeviceIcon(deviceSection.address, deviceSection.modelData?.icon ?? "") + color: root.batteryColorForLevel(circleDel.modelData.level) + } + + MaterialIcon { + visible: circleDel.modelData.charging + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } + text: "bolt" + color: Colours.palette.m3tertiary + fontStyle: Tokens.font.icon.small + } + } + + StyledText { + Layout.alignment: Qt.AlignHCenter + text: `${circleDel.modelData.level}%` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } + } + } + } + + // ── Divider before controls ─────────────────────────────────── + Rectangle { + readonly property bool hasBatteries: (GlobalConfig.services?.bluetoothHoverShowBatteries ?? true) && (deviceSection.bbmData !== null || deviceSection.modelData?.batteryAvailable === true) + readonly property bool hasControls: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) && deviceSection.bbmData !== null && ((deviceSection.bbmData?.Toggle1Visible ?? false) || (deviceSection.bbmData?.Toggle2Visible ?? false)) + + visible: hasBatteries && hasControls + Layout.fillWidth: true + height: 1 + color: Qt.alpha(Colours.palette.m3onSurface, 0.10) + } + + // ── Toggle rows (Noise Control etc.) ────────────────────────── + Repeater { + model: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) ? BbmService.togglesFor(deviceSection.bbmData) : [] + + delegate: ColumnLayout { + id: toggleGroup + + required property var modelData + + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + StyledText { + visible: toggleGroup.modelData.title.length > 0 + text: toggleGroup.modelData.title + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + Repeater { + model: toggleGroup.modelData.buttons.map((name, i) => ({ + name, + icon: (toggleGroup.modelData.buttonIcons ?? [])[i] ?? "", + active: toggleGroup.modelData.state === (i + 1), + widgetId: toggleGroup.modelData.widgetId, + index: i, + addr: deviceSection.address + })) + + delegate: StyledRect { + id: pill + + required property var modelData + + readonly property string iconUrl: BbmService.toggleIconUrl(pill.modelData.icon ?? "") + + Layout.fillWidth: true + implicitHeight: pillIcon.size + Tokens.padding.small * 2 + radius: Tokens.rounding.medium + color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + Behavior on color { + Anim { + type: Anim.DefaultEffects + } + } + + StateLayer { + radius: parent.radius + color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(pill.modelData.addr, pill.modelData.widgetId, pill.modelData.index + 1) + } + + BbmIcon { + id: pillIcon + + anchors.centerIn: parent + url: pill.iconUrl + visible: url.length > 0 + size: 18 + colour: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + + StyledText { + anchors.centerIn: parent + visible: pill.iconUrl.length === 0 + text: pill.modelData.name + color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + font: Tokens.font.body.small + } + } + } + } + } + } + + // ── NC intensity level sub-row ───────────────────────────────── + Item { + id: ancLevelContainer + + readonly property bool showing: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) && (deviceSection.bbmData?.OptionsBoxVisible ?? 0) === 1 && (deviceSection.bbmData?.AncLevelButtons ?? []).length > 0 + + Layout.fillWidth: true + implicitHeight: showing ? ancLevelLayout.implicitHeight : 0 + clip: true + + Behavior on implicitHeight { + Anim {} + } + + ColumnLayout { + id: ancLevelLayout + + width: parent.width + spacing: Tokens.spacing.extraSmall + opacity: ancLevelContainer.showing ? 1 : 0 + scale: ancLevelContainer.showing ? 1 : 0.92 + transformOrigin: Item.Top + + Behavior on opacity { + Anim { + type: Anim.StandardSmall + } + } + + Behavior on scale { + Anim { + type: Anim.StandardSmall + } + } + + StyledText { + visible: (deviceSection.bbmData?.AncLevelTitle ?? "").length > 0 + text: deviceSection.bbmData?.AncLevelTitle ?? "" + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + Repeater { + model: (deviceSection.bbmData?.AncLevelButtons ?? []).map((name, i) => ({ + name, + active: (deviceSection.bbmData?.AncLevelState ?? 0) === (i + 1), + index: i, + addr: deviceSection.address + })) + + delegate: StyledRect { + id: levelPill + + required property var modelData + + Layout.fillWidth: true + implicitHeight: levelLabel.implicitHeight + Tokens.padding.small * 2 + radius: Tokens.rounding.medium + color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + Behavior on color { + Anim { + type: Anim.DefaultEffects + } + } + + StateLayer { + radius: parent.radius + color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(levelPill.modelData.addr, BbmService.WIDGET_ANC_LEVEL, levelPill.modelData.index + 1) + } + + StyledText { + id: levelLabel + + anchors.centerIn: parent + text: levelPill.modelData.name + color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + font: Tokens.font.body.small + } + } + } + } + } + } + } + } + } +} diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml index 4595261769..9342ef74ea 100644 --- a/modules/nexus/pages/ServicesPage.qml +++ b/modules/nexus/pages/ServicesPage.qml @@ -1,8 +1,10 @@ import QtQuick +import QtQuick.Dialogs import QtQuick.Layouts import Quickshell import Caelestia.Config import Caelestia.Services +import qs.components import qs.components.controls import qs.services import qs.modules.nexus.common @@ -42,6 +44,103 @@ PageBase { } ] readonly property list gpuValues: ["", "NVIDIA", "GENERIC", "None"] + readonly property list batteryIndicatorItems: [ + MenuItem { + text: qsTr("Icon only") + }, + MenuItem { + text: qsTr("Text only") + }, + MenuItem { + text: qsTr("Icon + text") + }, + MenuItem { + text: qsTr("Disabled") + } + ] + readonly property list batteryIndicatorValues: [0, 1, 2, 3] + readonly property list indicatorStyleItems: [ + MenuItem { + text: qsTr("System tray") + }, + MenuItem { + text: qsTr("Panel button") + }, + MenuItem { + text: qsTr("Compact badge") + } + ] + readonly property list indicatorStyleValues: [0, 1, 2] + readonly property list hideIndicatorItems: [ + MenuItem { + text: qsTr("Always show") + }, + MenuItem { + text: qsTr("Hide when visible") + }, + MenuItem { + text: qsTr("Only connected") + } + ] + readonly property list hideIndicatorValues: [0, 1, 2] + readonly property list batteryLevelIndicatorItems: [ + MenuItem { + text: qsTr("Bar") + }, + MenuItem { + text: qsTr("Dots") + }, + MenuItem { + text: qsTr("None") + } + ] + readonly property list batteryLevelIndicatorValues: [0, 1, 2] + readonly property list batteryPaletteItems: [ + MenuItem { + text: qsTr("Adaptive") + }, + MenuItem { + text: qsTr("Accent") + }, + MenuItem { + text: qsTr("Custom") + } + ] + readonly property list batteryPaletteValues: [0, 1, 2] + + function batteryIndicatorIndexFromState(): int { + if (!GlobalConfig.services) + return 2; + const iconEnabled = Boolean(GlobalConfig.services.bluetoothBatteryIcon); + const textEnabled = Boolean(GlobalConfig.services.bluetoothBatteryText); + if (!iconEnabled && !textEnabled) + return 3; + if (iconEnabled && !textEnabled) + return 0; + if (!iconEnabled && textEnabled) + return 1; + return 2; + } + + function syncBatteryIndicatorType(): void { + if (!GlobalConfig.services) + return; + const idx = batteryIndicatorIndexFromState(); + const safeIndex = Number.isFinite(idx) ? idx : 2; + GlobalConfig.services.bluetoothBatteryIndicatorType = batteryIndicatorValues[safeIndex]; + } + + function batteryIndicatorModeForButtonIndex(idx: int): void { + if (!GlobalConfig.services) + return; + const safeIdx = Number.isFinite(idx) ? idx : 2; + const mode = batteryIndicatorValues[safeIdx] ?? 2; + const showIcon = mode === 0 || mode === 2; + const showText = mode === 1 || mode === 2; + GlobalConfig.services.bluetoothBatteryIcon = showIcon; + GlobalConfig.services.bluetoothBatteryText = showText; + GlobalConfig.services.bluetoothBatteryIndicatorType = mode; + } function gpuKeyToIndex(key: string): int { const u = (key ?? "").trim().toUpperCase(); @@ -77,9 +176,294 @@ PageBase { } } - // Notifications + // Bluetooth SectionHeader { first: true + text: qsTr("Bluetooth") + } + + ToggleRow { + first: true + text: qsTr("Bluetooth battery support") + subtext: qsTr("Enable Bluetooth battery indicators and quick-menu status") + checked: Boolean(GlobalConfig.services && (GlobalConfig.services.bluetoothBatteryIcon || GlobalConfig.services.bluetoothBatteryText || GlobalConfig.services.bluetoothBatteryRing)) + onToggled: { + const enabled = Boolean(checked); + if (GlobalConfig.services) { + GlobalConfig.services.bluetoothBatteryIcon = enabled; + GlobalConfig.services.bluetoothBatteryText = enabled && Boolean(GlobalConfig.services.bluetoothBatteryText); + GlobalConfig.services.bluetoothBatteryRing = enabled; + } + } + } + + ToggleRow { + text: qsTr("Show battery icon") + subtext: qsTr("Display the Bluetooth battery icon in the quick menu") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryIcon) + onToggled: { + if (!GlobalConfig.services) + return; + GlobalConfig.services.bluetoothBatteryIcon = checked; + root.syncBatteryIndicatorType(); + } + } + + ToggleRow { + text: qsTr("Show battery text") + subtext: qsTr("Display the battery percentage beside the Bluetooth icon") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryText) + onToggled: { + if (!GlobalConfig.services) + return; + GlobalConfig.services.bluetoothBatteryText = checked; + root.syncBatteryIndicatorType(); + } + } + + SelectRow { + label: qsTr("Battery indicator style") + subtext: qsTr("Choose how the Bluetooth battery is shown") + menuItems: root.batteryIndicatorItems + active: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryIndicatorType : undefined; + const idx = Number.isFinite(mode) ? root.batteryIndicatorValues.indexOf(mode) : 2; + return root.batteryIndicatorItems[Math.max(0, Math.min(root.batteryIndicatorItems.length - 1, idx))]; + } + onSelected: item => { + if (!GlobalConfig.services) + return; + const idx = root.batteryIndicatorItems.indexOf(item); + if (idx >= 0) + root.batteryIndicatorModeForButtonIndex(idx); + } + } + + SelectRow { + label: qsTr("Battery level indicator") + subtext: qsTr("Choose the BBM-style level glyph used inside the battery icon") + menuItems: root.batteryLevelIndicatorItems + active: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryLevelIndicatorType : 0; + const idx = Number.isFinite(mode) ? root.batteryLevelIndicatorValues.indexOf(mode) : 0; + return root.batteryLevelIndicatorItems[Math.max(0, Math.min(root.batteryLevelIndicatorItems.length - 1, idx))]; + } + onSelected: item => { + if (!GlobalConfig.services) + return; + const idx = root.batteryLevelIndicatorItems.indexOf(item); + if (idx >= 0) + GlobalConfig.services.bluetoothBatteryLevelIndicatorType = root.batteryLevelIndicatorValues[idx]; + } + } + + ToggleRow { + text: qsTr("Swap icon and text") + subtext: qsTr("Place the battery icon before the percentage text") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatterySwapIconText) + enabled: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryIcon && GlobalConfig.services.bluetoothBatteryText) + onToggled: { + if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatterySwapIconText = checked; + } + } + + ToggleRow { + text: qsTr("Show battery ring") + subtext: qsTr("Display the circular battery indicator around connected devices") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryRing) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryRing = checked + } + + ToggleRow { + text: qsTr("Open battery popup in quick settings") + subtext: qsTr("Show the Bluetooth battery details in a nested popup instead of inline rows") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryPopupInQuickSettings) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryPopupInQuickSettings = checked + } + + ToggleRow { + text: qsTr("Sort devices by recent use") + subtext: qsTr("Keep connected or recently used devices at the top of the list") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatterySortByHistory) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatterySortByHistory = checked + } + + SelectRow { + label: qsTr("Indicator visibility") + subtext: qsTr("Choose when the Bluetooth indicator should be hidden") + menuItems: root.hideIndicatorItems + active: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryHideIndicatorMode : 0; + const idx = Number.isFinite(mode) ? root.hideIndicatorValues.indexOf(mode) : 0; + return root.hideIndicatorItems[Math.max(0, Math.min(root.hideIndicatorItems.length - 1, idx))]; + } + onSelected: item => { + if (!GlobalConfig.services) + return; + const idx = root.hideIndicatorItems.indexOf(item); + if (idx >= 0) + GlobalConfig.services.bluetoothBatteryHideIndicatorMode = root.hideIndicatorValues[idx]; + } + } + + SelectRow { + label: qsTr("Indicator placement") + subtext: qsTr("Choose where the Bluetooth battery indicator appears") + menuItems: root.indicatorStyleItems + active: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryIndicatorStyle : 1; + const idx = Number.isFinite(mode) ? root.indicatorStyleValues.indexOf(mode) : 1; + return root.indicatorStyleItems[Math.max(0, Math.min(root.indicatorStyleItems.length - 1, idx))]; + } + onSelected: item => { + if (!GlobalConfig.services) + return; + const idx = root.indicatorStyleItems.indexOf(item); + if (idx >= 0) + GlobalConfig.services.bluetoothBatteryIndicatorStyle = root.indicatorStyleValues[idx]; + } + } + + ToggleRow { + text: qsTr("Use multi-indicator mode") + subtext: qsTr("Show each device battery as a separate indicator when multiple devices are connected") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryMultiIndicator) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryMultiIndicator = checked + } + + ToggleRow { + text: qsTr("Use hover mode") + subtext: qsTr("Only reveal the extended battery view when the pointer hovers over the indicator") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryHoverMode) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryHoverMode = checked + } + + StepperRow { + label: qsTr("Hover delay") + subtext: qsTr("Delay before the hover battery popup is shown (ms)") + value: GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryHoverDelay : 1000 + from: 250 + to: 5000 + stepSize: 50 + onMoved: v => { + if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryHoverDelay = Math.round(v); + } + } + + ToggleRow { + text: qsTr("Show battery tooltips") + subtext: qsTr("Display the device battery and status when hovering over the Bluetooth icon") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryTooltips) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryTooltips = checked + } + + ToggleRow { + text: qsTr("Use per-device defaults") + subtext: qsTr("Let each Bluetooth device keep its own preferred battery display and behaviour") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryPerDeviceDefaults) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryPerDeviceDefaults = checked + } + + ToggleRow { + text: qsTr("Enable UPower device support") + subtext: qsTr("Include extra battery-aware devices that expose power data through UPower") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryUpowerSupport = checked + } + + ToggleRow { + text: qsTr("Use custom battery colours") + subtext: qsTr("Apply a custom per-bucket battery palette instead of the default adaptive colours") + checked: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUseCustomColors) + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryUseCustomColors = checked + } + + SelectRow { + label: qsTr("Battery colour scheme") + subtext: qsTr("Choose which battery colour palette is used for device percentages") + menuItems: root.batteryPaletteItems + active: { + const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryColorScheme : 0; + const idx = Number.isFinite(mode) ? root.batteryPaletteValues.indexOf(mode) : 0; + return root.batteryPaletteItems[Math.max(0, Math.min(root.batteryPaletteItems.length - 1, idx))]; + } + onSelected: item => { + if (!GlobalConfig.services) + return; + const idx = root.batteryPaletteItems.indexOf(item); + if (idx >= 0) + GlobalConfig.services.bluetoothBatteryColorScheme = root.batteryPaletteValues[idx]; + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.small + visible: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUseCustomColors) + + Repeater { + model: 10 + + Rectangle { + width: 22 + height: 22 + radius: 6 + border.width: 1 + border.color: Colours.palette.m3outline + color: (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryColors && GlobalConfig.services.bluetoothBatteryColors[index]) || "#4ade80" + + StateLayer { + anchors.fill: parent + onClicked: { + if (!GlobalConfig.services) + return; + const list = GlobalConfig.services.bluetoothBatteryColors.slice(); + colorDialog.bucketIndex = index; + colorDialog.selectedColor = list[index] || "#4ade80"; + colorDialog.open(); + } + } + } + } + } + + ColorDialog { + id: colorDialog + + property int bucketIndex: 0 + + title: qsTr("Choose battery colour") + onAccepted: { + if (!GlobalConfig.services) + return; + const list = GlobalConfig.services.bluetoothBatteryColors.slice(); + list[colorDialog.bucketIndex] = colorDialog.color.toString().toUpperCase(); + GlobalConfig.services.bluetoothBatteryColors = list; + } + } + + NavRow { + first: true + last: true + icon: "device_hub" + text: qsTr("Manage device defaults") + subtext: qsTr("Configure per-device icon, quick menu and indicator behaviour") + onClicked: root.nState.openSubPage(2) + } + + // Notifications + SectionHeader { text: qsTr("Notifications") } diff --git a/modules/nexus/pages/services/BtDeviceDefaultsPage.qml b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml new file mode 100644 index 0000000000..cdd4cc9020 --- /dev/null +++ b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml @@ -0,0 +1,139 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Layouts +import Quickshell +import Quickshell.Bluetooth +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.components.effects +import qs.services +import qs.utils +import qs.modules.nexus.common + +PageBase { + id: root + + title: qsTr("Device defaults") + isSubPage: true + + ColumnLayout { + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + width: root.cappedWidth + spacing: Tokens.spacing.extraSmall / 2 + + SectionHeader { + first: true + text: qsTr("Paired devices") + } + + Repeater { + id: deviceRepeater + + model: ScriptModel { + values: (Bluetooth.devices.values ?? []) // qmllint disable unresolved-type + .filter(d => d.bonded).sort((a, b) => (b.connected - a.connected) || a.name.localeCompare(b.name)) + } + + delegate: ConnectedRect { + id: deviceRow + + required property BluetoothDevice modelData // qmllint disable unresolved-type + required property int index + + readonly property string bbmUrl: BbmService.deviceIconUrl(deviceRow.modelData.address, deviceRow.modelData.icon ?? "") + + Layout.fillWidth: true + implicitHeight: rowContent.implicitHeight + Tokens.padding.medium * 2 + first: deviceRow.index === 0 + last: deviceRow.index === deviceRepeater.count - 1 + + StateLayer { + onClicked: { + root.nState.selectedBtDevice = deviceRow.modelData; + root.nState.openSubPage(3); + } + } + + RowLayout { + id: rowContent + + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.margins: Tokens.padding.largeIncreased + spacing: Tokens.spacing.medium + + // Device icon — BBM SVG or Material fallback + Item { + id: iconItem + + implicitWidth: matFallback.implicitWidth + implicitHeight: matFallback.implicitHeight + + ColouredIcon { + anchors.centerIn: parent + source: deviceRow.bbmUrl + visible: deviceRow.bbmUrl.length > 0 + width: matFallback.implicitWidth + height: matFallback.implicitHeight + colour: Colours.palette.m3onSurfaceVariant + } + + MaterialIcon { + id: matFallback + + anchors.centerIn: parent + // "bluetooth" used as size reference when BBM icon is active + text: deviceRow.bbmUrl.length > 0 ? "bluetooth" : Icons.getDeviceIcon(deviceRow.modelData.address, deviceRow.modelData.icon ?? "") + visible: deviceRow.bbmUrl.length === 0 + color: Colours.palette.m3onSurfaceVariant + fontStyle: Tokens.font.icon.medium + fill: 1 + } + } + + Column { + Layout.fillWidth: true + spacing: 0 + + StyledText { + anchors.left: parent.left + anchors.right: parent.right + text: deviceRow.modelData.name + font: Tokens.font.body.small + elide: Text.ElideRight + } + + StyledText { + anchors.left: parent.left + anchors.right: parent.right + text: deviceRow.modelData.connected ? qsTr("Connected") : qsTr("Saved") + color: Colours.palette.m3outline + font: Tokens.font.label.small + elide: Text.ElideRight + } + } + + MaterialIcon { + text: "chevron_right" + color: Colours.palette.m3onSurfaceVariant + fontStyle: Tokens.font.icon.medium + } + } + } + } + + // Empty state + StyledText { + visible: deviceRepeater.count === 0 + Layout.alignment: Qt.AlignHCenter + Layout.topMargin: Tokens.spacing.large + text: qsTr("No paired devices") + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + } +} diff --git a/nix/bbm.nix b/nix/bbm.nix new file mode 100644 index 0000000000..92c6aeaaf6 --- /dev/null +++ b/nix/bbm.nix @@ -0,0 +1,49 @@ +{ + lib, + stdenv, + makeWrapper, + gjs, + glib, +}: +stdenv.mkDerivation { + pname = "caelestia-bbm-daemon"; + version = "1.0.0"; + + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.unions [ + ./../bbm/daemon + ./../bbm/lib + ./../bbm/icons + ]; + }; + + nativeBuildInputs = [makeWrapper glib]; + + dontBuild = true; + + installPhase = '' + runHook preInstall + + install -d $out/share/bbm + cp -r bbm/daemon $out/share/bbm/daemon + cp -r bbm/lib $out/share/bbm/lib + cp -r bbm/icons $out/share/bbm/icons + + # Compile GSettings schemas for the target system's GLib version + glib-compile-schemas $out/share/bbm/daemon/schemas/ + + install -d $out/bin + makeWrapper ${gjs}/bin/gjs $out/bin/bbm-daemon \ + --add-flags "-m $out/share/bbm/daemon/main.js" + + runHook postInstall + ''; + + meta = { + description = "Bluetooth Battery Meter headless D-Bus daemon"; + homepage = "https://github.com/maniacx/Bluetooth-Battery-Meter"; + license = lib.licenses.gpl2Only; + mainProgram = "bbm-daemon"; + }; +} diff --git a/nix/default.nix b/nix/default.nix index aea75f234b..63e3344b7d 100644 --- a/nix/default.nix +++ b/nix/default.nix @@ -13,6 +13,7 @@ wl-clipboard, libqalculate, bash, + glib, hyprland, material-symbols, rubik, @@ -48,6 +49,7 @@ wl-clipboard libqalculate bash + glib hyprland ] ++ extraRuntimeDeps diff --git a/nix/hm-module.nix b/nix/hm-module.nix index 2976cf6171..8f06cccc1d 100644 --- a/nix/hm-module.nix +++ b/nix/hm-module.nix @@ -8,6 +8,7 @@ self: { cli-default = self.inputs.caelestia-cli.packages.${system}.default; shell-default = self.packages.${system}.with-cli; + bbm-default = self.packages.${system}.bbm-daemon; cfg = config.programs.caelestia; in { @@ -54,6 +55,18 @@ in { default = ""; description = "Caelestia shell extra configs written to shell.json"; }; + bbm = { + enable = mkOption { + type = types.bool; + default = true; + description = "Enable the BBM daemon systemd service (Bluetooth battery + ANC controls)"; + }; + package = mkOption { + type = types.package; + default = bbm-default; + description = "The BBM daemon package"; + }; + }; cli = { enable = mkEnableOption "Enable Caelestia CLI"; package = mkOption { @@ -80,6 +93,26 @@ in { shell = cfg.package; in lib.mkIf cfg.enable { + systemd.user.services.bbm-daemon = lib.mkIf cfg.bbm.enable { + Unit = { + Description = "Bluetooth Battery Meter daemon"; + After = ["bluetooth.target" cfg.systemd.target]; + PartOf = [cfg.systemd.target]; + }; + + Service = { + Type = "simple"; + ExecStart = "${cfg.bbm.package}/bin/bbm-daemon"; + Restart = "on-failure"; + RestartSec = "5s"; + TimeoutStopSec = "5s"; + }; + + Install = { + WantedBy = [cfg.systemd.target]; + }; + }; + systemd.user.services.caelestia = lib.mkIf cfg.systemd.enable { Unit = { Description = "Caelestia Shell Service"; @@ -131,6 +164,8 @@ in { }; }; - home.packages = [shell] ++ lib.optional cfg.cli.enable cli; + home.packages = [shell] + ++ lib.optional cfg.bbm.enable cfg.bbm.package + ++ lib.optional cfg.cli.enable cli; }; } diff --git a/plugin/src/Caelestia/Config/serviceconfig.hpp b/plugin/src/Caelestia/Config/serviceconfig.hpp index ffdccda6bd..2b7cbf0140 100644 --- a/plugin/src/Caelestia/Config/serviceconfig.hpp +++ b/plugin/src/Caelestia/Config/serviceconfig.hpp @@ -33,6 +33,27 @@ class ServiceConfig : public ConfigObject { CONFIG_GLOBAL_PROPERTY(QVariantList, playerAliases, { vmap({ { u"from"_s, u"com.github.th_ch.youtube_music"_s }, { u"to"_s, u"YT Music"_s } }) }) CONFIG_GLOBAL_PROPERTY(QString, lyricsBackend, u"Auto"_s) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryIcon, true) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryText, false) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatterySwapIconText, false) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryIndicatorType, 2) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryRing, true) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryPopupInQuickSettings, false) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatterySortByHistory, false) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryHideIndicatorMode, 0) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryIndicatorStyle, 1) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryMultiIndicator, false) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryHoverMode, true) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryHoverDelay, 1000) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryTooltips, true) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryPerDeviceDefaults, true) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryUpowerSupport, false) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryLevelIndicatorType, 0) + CONFIG_GLOBAL_PROPERTY(bool, bluetoothBatteryUseCustomColors, false) + CONFIG_GLOBAL_PROPERTY(int, bluetoothBatteryColorScheme, 0) + CONFIG_GLOBAL_PROPERTY(QStringList, bluetoothBatteryColors, + { u"#4ade80"_s, u"#4ade80"_s, u"#65d66d"_s, u"#8ae06d"_s, u"#b7db6d"_s, + u"#facc15"_s, u"#f59e0b"_s, u"#fb923c"_s, u"#f87171"_s, u"#ef4444"_s }) public: explicit ServiceConfig(QObject* parent = nullptr) diff --git a/services/BbmService.qml b/services/BbmService.qml new file mode 100644 index 0000000000..a16572d85b --- /dev/null +++ b/services/BbmService.qml @@ -0,0 +1,338 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io +import Caelestia.Config + +Singleton { + id: root + + readonly property string _dest: "org.bbm" + readonly property string _path: "/org/bbm" + readonly property string _iface: "org.bbm.Manager" + + readonly property int _retryIntervalMs: 5000 + readonly property string STATUS_CHARGING: "charging" + readonly property string WIDGET_ANC_LEVEL: "box1RadioButtonState" + + // True when the org.bbm daemon is reachable on the session bus. + property bool available: false + + // Absolute path to the BBM icon directory (set once from GetIconPath on connect). + property string iconBasePath: "" + + // address → flat JS object matching GetDeviceData's a{sv} keys. + property var deviceMap: ({}) + + function dataFor(address) { + return deviceMap[address] ?? null; + } + + // Returns a file:// URL for a battery component icon (e.g. "earbuds-stem-left"). + // The BBM convention wraps the short name: bbm--symbolic.svg + // encodeURI handles spaces in the icon directory path. + function batteryIconUrl(iconName) { + if (!iconName || !iconBasePath) + return ""; + return "file://" + encodeURI(iconBasePath + "/bbm-" + iconName + "-symbolic.svg"); + } + + // Returns a file:// URL for a toggle button icon (e.g. "bbm-anc-on-symbolic"). + // These names are already fully qualified — just append .svg. + function toggleIconUrl(iconName) { + if (!iconName || !iconBasePath) + return ""; + return "file://" + encodeURI(iconBasePath + "/" + iconName + ".svg"); + } + + // Maps a BlueZ device icon name to a BBM icon name, or returns null if unknown. + function _bluezToBbm(bluezIcon) { + const overrideMap = { + 'phone-apple-iphone-symbolic': 'phone', + 'phone-apple-iphone': 'phone', + 'phone-google-nexus-one': 'phone', + 'phone-samsung-galaxy-s': 'phone' + }; + if (overrideMap[bluezIcon]) + return overrideMap[bluezIcon]; + const direct = ['audio-card', 'audio-speakers', 'audio-speakers2', 'audio-speakers3', 'audio-headphones', 'audio-headset', 'headphone1', 'earbuds', 'earbuds-wingtip', 'earbuds-wingtip2', 'earbuds-neckband', 'earbuds-stem', 'earbuds-stem2', 'earbuds-stem3', 'earbuds-bean', 'input-microphone', 'input-gaming', 'input-gaming2', 'input-gaming3', 'input-keyboard', 'input-keyboard2', 'input-split-keyboard', 'input-mouse', 'input-tablet', 'touchpad', 'phone', 'multimedia-player', 'pda', 'camera-photo', 'camera-video', 'computer', 'video-display', 'printer', 'scanner', 'modem', 'network-wireless', 'wearable', 'wearable2',]; + return direct.includes(bluezIcon) ? bluezIcon : null; + } + + // Returns the BBM icon name for a device, respecting (in order): + // 1. User override in services.bluetoothDeviceIcons config + // 2. DeviceIcon reported by the BBM daemon (albumArtIcon from device config) + // 3. BlueZ icon field mapped to the closest BBM icon name + function _deviceIconName(address, btIconFallback) { + const overrides = GlobalConfig.services?.bluetoothDeviceIcons ?? []; + for (const e of overrides) + if (e?.address === address && e?.icon) + return e.icon; + const data = root.deviceMap[address]; + if (data?.DeviceIcon) + return data.DeviceIcon; + return root._bluezToBbm(btIconFallback ?? ""); + } + + // Returns a file:// URL for the device-level icon (e.g. the whole device silhouette). + // btIconFallback is the BlueZ device.icon field used when BBM hasn't reported a specific icon. + function deviceIconUrl(address, btIconFallback) { + const name = root._deviceIconName(address, btIconFallback ?? ""); + return name ? root.batteryIconUrl(name) : ""; + } + + function batteriesFor(bbmData) { + if (!bbmData) + return []; + return [ + { + icon: bbmData.Battery1Icon ?? "", + level: bbmData.Battery1Level ?? 0, + charging: (bbmData.Battery1Status ?? "") === root.STATUS_CHARGING + }, + { + icon: bbmData.Battery2Icon ?? "", + level: bbmData.Battery2Level ?? 0, + charging: (bbmData.Battery2Status ?? "") === root.STATUS_CHARGING + }, + { + icon: bbmData.Battery3Icon ?? "", + level: bbmData.Battery3Level ?? 0, + charging: (bbmData.Battery3Status ?? "") === root.STATUS_CHARGING + }, + ].filter(b => b.level > 0); + } + + function togglesFor(bbmData) { + if (!bbmData) + return []; + return [ + { + title: bbmData.Toggle1Title ?? "", + buttons: bbmData.Toggle1Buttons ?? [], + buttonIcons: bbmData.Toggle1ButtonIcons ?? [], + state: bbmData.Toggle1State ?? 0, + visible: bbmData.Toggle1Visible ?? false, + widgetId: "toggle1State" + }, + { + title: bbmData.Toggle2Title ?? "", + buttons: bbmData.Toggle2Buttons ?? [], + buttonIcons: bbmData.Toggle2ButtonIcons ?? [], + state: bbmData.Toggle2State ?? 0, + visible: bbmData.Toggle2Visible ?? false, + widgetId: "toggle2State" + }, + ].filter(t => t.visible && t.buttons.length > 0); + } + + function sendUIAction(address, widgetId, value) { + sendActionComp.createObject(root, { + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.SendUIAction`, `'${address}'`, `'${widgetId}'`, String(value)] + }).running = true; + } + + function _fetchDevice(address) { + fetchDeviceComp.createObject(root, { + addr: address + }).running = true; + } + + function _init() { + if (!monitor.running) + monitor.running = true; + // Start listProc on the next tick so monitor connects to D-Bus before + // we enumerate devices — prevents signals emitted between list response + // and monitor startup from being silently dropped. + Qt.callLater(() => { + if (!listProc.running) + listProc.running = true; + if (!iconPathProc.running) + iconPathProc.running = true; + }); + } + + // ── GVariant text parsers ───────────────────────────────────────────────── + + function _parseAddressList(text) { + const re = /'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})'/g; + const addrs = []; + let m; + while ((m = re.exec(text)) !== null) + addrs.push(m[1]); + return addrs; + } + + function _parseDeviceData(text) { + const s = text.trim(); + const result = {}; + const start = s.indexOf('{'); + const end = s.lastIndexOf('}'); + if (start < 0 || end < 0) + return result; + const inner = s.slice(start + 1, end); + let pos = 0; + while (pos < inner.length) { + const ks = inner.indexOf("'", pos); + if (ks < 0) + break; + const ke = inner.indexOf("'", ks + 1); + if (ke < 0) + break; + const key = inner.slice(ks + 1, ke); + pos = ke + 1; + const lt = inner.indexOf('<', pos); + if (lt < 0) + break; + pos = lt + 1; + let gt; + if (inner[pos] === '[') { + const arrEnd = inner.indexOf(']', pos); + gt = inner.indexOf('>', arrEnd >= 0 ? arrEnd : pos); + } else { + gt = inner.indexOf('>', pos); + } + if (gt < 0) + break; + const raw = inner.slice(pos, gt).trim(); + pos = gt + 1; + if (raw.startsWith("'") && raw.endsWith("'")) { + result[key] = raw.slice(1, -1); + } else if (raw === 'true') { + result[key] = true; + } else if (raw === 'false') { + result[key] = false; + } else if (raw.startsWith('@') || raw === '[]') { + result[key] = []; + } else if (raw.startsWith('[')) { + const content = raw.slice(1, -1).trim(); + result[key] = content ? content.split(/, (?=')/).map(item => item.replace(/^'|'$/g, '')) : []; + } else { + // GVariant type-annotated number: "int32 65", "uint32 3", "double 1.5", etc. + const typedNum = /^(?:byte|int16|uint16|int32|uint32|int64|uint64|double|handle) (-?\d+(?:\.\d+)?)$/.exec(raw); + if (typedNum) { + result[key] = Number(typedNum[1]); + } else { + const n = parseInt(raw, 10); + result[key] = isNaN(n) ? raw : n; + } + } + } + return result; + } + + Component.onCompleted: _init() + + // ── Process components ──────────────────────────────────────────────────── + + // Dynamic: one instance per GetDeviceData call, self-destroys on exit. + Component { + id: fetchDeviceComp + + Process { + property string addr: "" + + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.GetDeviceData`, `'${addr}'`] + stdout: StdioCollector { + onStreamFinished: { + const data = root._parseDeviceData(text); + if (data.Address) { + const map = Object.assign({}, root.deviceMap); + map[data.Address] = data; + root.deviceMap = map; + } + } + } + onExited: destroy() + } + } + + // Dynamic: fire-and-forget SendUIAction, self-destroys. + Component { + id: sendActionComp + + Process { + onExited: destroy() + } + } + + // One-shot: fetch the icon base path from the daemon (called once on connect). + Process { + id: iconPathProc + + running: false + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.GetIconPath`] + stdout: StdioCollector { + onStreamFinished: { + // Output format: ('/path/to/icons',)\n + const m = text.match(/'([^']+)'/); + if (m) + root.iconBasePath = m[1]; + } + } + } + + // ── Persistent monitor ──────────────────────────────────────────────────── + + Process { + id: monitor + + running: false + // stdbuf -oL forces line-buffering: gdbus switches to block-buffered + // stdout when piped (not a tty), so signals would silently queue until + // the 4-8KB buffer fills without this. + command: ["stdbuf", "-oL", "gdbus", "monitor", "--session", "--dest", root._dest, "--object-path", root._path] + stdout: SplitParser { + onRead: line => { + const ifaceEsc = root._iface.replace(/\./g, "\\."); + const m = line.match(new RegExp(ifaceEsc + "\\.(\\w+) \\('([^']+)',\\)")); + if (!m) + return; + const [, signal, address] = m; + if (signal === "DeviceAdded" || signal === "DeviceChanged") { + root._fetchDevice(address); + } else if (signal === "DeviceRemoved") { + const map = Object.assign({}, root.deviceMap); + delete map[address]; + root.deviceMap = map; + } + } + } + onExited: { + root.available = false; + root.deviceMap = {}; + retryTimer.restart(); + } + } + + // Static: reused for each GetDevices check. + Process { + id: listProc + + running: false + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.GetDevices`] + stdout: StdioCollector { + onStreamFinished: { + if (!text.trim().startsWith("(")) + return; + root.available = true; + root._parseAddressList(text).forEach(a => root._fetchDevice(a)); + } + } + onExited: (code, status) => { + if (code !== 0) { + root.available = false; + retryTimer.restart(); + } + } + } + + Timer { + id: retryTimer + + interval: root._retryIntervalMs + repeat: false + onTriggered: root._init() + } +} diff --git a/tests/bbm/qmldir b/tests/bbm/qmldir new file mode 100644 index 0000000000..53205e806d --- /dev/null +++ b/tests/bbm/qmldir @@ -0,0 +1,2 @@ +module bbm.test +BbmIcon 1.0 stubs/BbmIcon.qml diff --git a/tests/bbm/stubs/BbmIcon.qml b/tests/bbm/stubs/BbmIcon.qml new file mode 100644 index 0000000000..582d1530ec --- /dev/null +++ b/tests/bbm/stubs/BbmIcon.qml @@ -0,0 +1,47 @@ +import QtQuick +import qs.components.effects + +Item { + id: iconItem + + required property string url + required property color colour + property int size: 18 + + implicitWidth: size + implicitHeight: size + + Image { + id: img + + anchors.fill: parent + source: iconItem.url + sourceSize: Qt.size(iconItem.size, iconItem.size) + fillMode: Image.PreserveAspectFit + smooth: true + asynchronous: true + visible: status !== Image.Error + + layer.enabled: true + layer.effect: Colouriser { + sourceColor: analyser.dominantColour + colorizationColor: iconItem.colour + } + + layer.onEnabledChanged: { + if (layer.enabled && img.status === Image.Ready) + analyser.requestUpdate(); + } + + onStatusChanged: { + if (layer.enabled && img.status === Image.Ready) + analyser.requestUpdate(); + } + } + + ImageAnalyser { + id: analyser + + sourceItem: img + } +} diff --git a/tests/bbm/stubs/qs/components/effects/Colouriser.qml b/tests/bbm/stubs/qs/components/effects/Colouriser.qml new file mode 100644 index 0000000000..fa9216b408 --- /dev/null +++ b/tests/bbm/stubs/qs/components/effects/Colouriser.qml @@ -0,0 +1,6 @@ +import QtQuick + +ShaderEffect { + property color sourceColor: "transparent" + property color colorizationColor: "transparent" +} diff --git a/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml new file mode 100644 index 0000000000..10318d9234 --- /dev/null +++ b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml @@ -0,0 +1,9 @@ +import QtQuick + +Item { + property var sourceItem: null + property color dominantColour: "transparent" + + function requestUpdate() { + } +} diff --git a/tests/bbm/stubs/qs/components/effects/qmldir b/tests/bbm/stubs/qs/components/effects/qmldir new file mode 100644 index 0000000000..1ce87a9a8f --- /dev/null +++ b/tests/bbm/stubs/qs/components/effects/qmldir @@ -0,0 +1,3 @@ +module qs.components.effects +Colouriser 1.0 Colouriser.qml +ImageAnalyser 1.0 ImageAnalyser.qml diff --git a/tests/bbm/test-bbm-logic.mjs b/tests/bbm/test-bbm-logic.mjs new file mode 100644 index 0000000000..42b711329b --- /dev/null +++ b/tests/bbm/test-bbm-logic.mjs @@ -0,0 +1,340 @@ +/** + * BBM QML business-logic unit tests (Node.js) + * + * Tests the JS functions extracted/refactored in BbmService.qml, + * BbmExpandedRow.qml, BtDeviceHover.qml, and Bluetooth.qml. + * + * Qt/QML globals (Qt.rgba, Colours) are mocked so tests run without + * a QML runtime. + */ + +// ── Mock Qt API ─────────────────────────────────────────────────────────────── +const Qt = { + rgba: (r, g, b, a) => ({ r, g, b, a }), +}; + +// ── Mock Colours (same palette role names used by the real singleton) ───────── +const ERROR = { r: 1.00, g: 0.20, b: 0.20, a: 1 }; +const TERTIARY = { r: 0.50, g: 0.80, b: 0.50, a: 1 }; +const PRIMARY = { r: 0.20, g: 0.50, b: 1.00, a: 1 }; +const Colours = { palette: { m3error: ERROR, m3tertiary: TERTIARY, m3primary: PRIMARY } }; + +// ── Extracted constants (from BbmService.qml) ───────────────────────────────── +const STATUS_CHARGING = "charging"; +const WIDGET_ANC_LEVEL = "box1RadioButtonState"; +const _retryIntervalMs = 5000; + +// ── batteriesFor (from BbmService.qml) ─────────────────────────────────────── +function batteriesFor(bbmData) { + if (!bbmData) return []; + return [ + { icon: bbmData.Battery1Icon ?? "", level: bbmData.Battery1Level ?? 0, charging: (bbmData.Battery1Status ?? "") === STATUS_CHARGING }, + { icon: bbmData.Battery2Icon ?? "", level: bbmData.Battery2Level ?? 0, charging: (bbmData.Battery2Status ?? "") === STATUS_CHARGING }, + { icon: bbmData.Battery3Icon ?? "", level: bbmData.Battery3Level ?? 0, charging: (bbmData.Battery3Status ?? "") === STATUS_CHARGING }, + ].filter(b => b.level > 0); +} + +// ── togglesFor (from BbmService.qml) ───────────────────────────────────────── +function togglesFor(bbmData) { + if (!bbmData) return []; + return [ + { title: bbmData.Toggle1Title ?? "", buttons: bbmData.Toggle1Buttons ?? [], buttonIcons: bbmData.Toggle1ButtonIcons ?? [], state: bbmData.Toggle1State ?? 0, visible: bbmData.Toggle1Visible ?? false, widgetId: "toggle1State" }, + { title: bbmData.Toggle2Title ?? "", buttons: bbmData.Toggle2Buttons ?? [], buttonIcons: bbmData.Toggle2ButtonIcons ?? [], state: bbmData.Toggle2State ?? 0, visible: bbmData.Toggle2Visible ?? false, widgetId: "toggle2State" }, + ].filter(t => t.visible && t.buttons.length > 0); +} + +// ── batteryColorForLevel (local copy in all three popup files) ──────────────── +function batteryColorForLevel(levelPercent) { + const level = Math.max(0, Math.min(100, Number(levelPercent) || 0)); + const error = Colours.palette.m3error; + const tertiary = Colours.palette.m3tertiary; + const primary = Colours.palette.m3primary; + function lerp(a, b, t) { + return Qt.rgba( + a.r + (b.r - a.r) * t, + a.g + (b.g - a.g) * t, + a.b + (b.b - a.b) * t, + 1 + ); + } + if (level <= 15) return error; + if (level <= 40) return lerp(error, tertiary, (level - 15) / 25); + return lerp(tertiary, primary, (level - 40) / 60); +} + +// ── Micro test runner ───────────────────────────────────────────────────────── +let passed = 0, failed = 0; + +function group(name) { + console.log(`\n${name}`); +} + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.error(` ✗ ${name}`); + console.error(` ${e.message}`); + failed++; + } +} + +function eq(a, b, msg) { + const as = JSON.stringify(a), bs = JSON.stringify(b); + if (as !== bs) throw new Error(msg ?? `expected ${bs} got ${as}`); +} + +function approx(a, b, name, eps = 1e-9) { + if (Math.abs(a - b) > eps) + throw new Error(`${name}: expected ≈${b} got ${a}`); +} + +function colourApprox(c, r, g, b, label) { + approx(c.r, r, `${label}.r`); + approx(c.g, g, `${label}.g`); + approx(c.b, b, `${label}.b`); + approx(c.a, 1, `${label}.a`); +} + +// ═════════════════════════════════════════════════════════════════════════════ +// 1. Constants +// ═════════════════════════════════════════════════════════════════════════════ +group("Constants"); +test("STATUS_CHARGING === 'charging'", () => eq(STATUS_CHARGING, "charging")); +test("WIDGET_ANC_LEVEL === 'box1RadioButtonState'", () => eq(WIDGET_ANC_LEVEL, "box1RadioButtonState")); +test("_retryIntervalMs === 5000", () => eq(_retryIntervalMs, 5000)); + +// ═════════════════════════════════════════════════════════════════════════════ +// 2. batteriesFor +// ═════════════════════════════════════════════════════════════════════════════ +group("batteriesFor"); + +test("null → []", () => eq(batteriesFor(null), [])); +test("undefined → []", () => eq(batteriesFor(undefined), [])); + +test("all levels 0 → filtered to []", () => eq( + batteriesFor({ Battery1Level: 0, Battery2Level: 0, Battery3Level: 0 }), + [] +)); + +test("single battery, level > 0", () => { + const result = batteriesFor({ Battery1Level: 75, Battery1Icon: "earbuds", Battery1Status: "discharging" }); + eq(result.length, 1); + eq(result[0].level, 75); + eq(result[0].icon, "earbuds"); + eq(result[0].charging, false); +}); + +test("charging:true when status === 'charging'", () => { + const r = batteriesFor({ Battery1Level: 50, Battery1Status: "charging" }); + eq(r[0].charging, true); +}); + +test("charging:false when status is empty string", () => { + const r = batteriesFor({ Battery1Level: 50, Battery1Status: "" }); + eq(r[0].charging, false); +}); + +test("charging:false when status is absent (falls back to '')", () => { + const r = batteriesFor({ Battery1Level: 50 }); + eq(r[0].charging, false); +}); + +test("all three batteries returned when all level > 0", () => { + const r = batteriesFor({ + Battery1Level: 80, Battery1Icon: "earbuds-left", Battery1Status: "discharging", + Battery2Level: 70, Battery2Icon: "earbuds-right", Battery2Status: "charging", + Battery3Level: 60, Battery3Icon: "earbuds-case", Battery3Status: "discharging", + }); + eq(r.length, 3); + eq(r[0], { icon: "earbuds-left", level: 80, charging: false }); + eq(r[1], { icon: "earbuds-right", level: 70, charging: true }); + eq(r[2], { icon: "earbuds-case", level: 60, charging: false }); +}); + +test("missing icon fields default to ''", () => { + const r = batteriesFor({ Battery1Level: 42 }); + eq(r[0].icon, ""); +}); + +test("only batteries with level > 0 are included (mixed)", () => { + const r = batteriesFor({ + Battery1Level: 90, + Battery2Level: 0, + Battery3Level: 30, + }); + eq(r.length, 2); + eq(r[0].level, 90); + eq(r[1].level, 30); +}); + +test("returns array (not mutated bbmData)", () => { + const data = { Battery1Level: 50 }; + batteriesFor(data); + eq(data.Battery1Level, 50); // unchanged +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// 3. togglesFor +// ═════════════════════════════════════════════════════════════════════════════ +group("togglesFor"); + +test("null → []", () => eq(togglesFor(null), [])); +test("undefined → []", () => eq(togglesFor(undefined), [])); + +test("both toggles invisible → []", () => eq( + togglesFor({ Toggle1Visible: false, Toggle2Visible: false }), + [] +)); + +test("visible but empty buttons → filtered out", () => eq( + togglesFor({ Toggle1Visible: true, Toggle1Buttons: [] }), + [] +)); + +test("visible with buttons → included", () => { + const r = togglesFor({ + Toggle1Visible: true, + Toggle1Buttons: ["Off", "ANC", "Transparency"], + Toggle1ButtonIcons: ["anc-off", "anc-on", "anc-trans"], + Toggle1State: 2, + Toggle1Title: "Noise Control", + }); + eq(r.length, 1); + eq(r[0].widgetId, "toggle1State"); + eq(r[0].title, "Noise Control"); + eq(r[0].buttons, ["Off", "ANC", "Transparency"]); + eq(r[0].state, 2); + eq(r[0].visible, true); +}); + +test("toggle2 has correct widgetId", () => { + const r = togglesFor({ + Toggle2Visible: true, + Toggle2Buttons: ["On", "Off"], + }); + eq(r[0].widgetId, "toggle2State"); +}); + +test("both visible, both have buttons → both included in order", () => { + const r = togglesFor({ + Toggle1Visible: true, Toggle1Buttons: ["a"], + Toggle2Visible: true, Toggle2Buttons: ["b"], + }); + eq(r.length, 2); + eq(r[0].widgetId, "toggle1State"); + eq(r[1].widgetId, "toggle2State"); +}); + +test("missing fields default correctly", () => { + const r = togglesFor({ Toggle1Visible: true, Toggle1Buttons: ["x"] }); + eq(r[0].title, ""); + eq(r[0].buttonIcons, []); + eq(r[0].state, 0); +}); + +test("visible:false even with buttons → excluded", () => eq( + togglesFor({ Toggle1Visible: false, Toggle1Buttons: ["a", "b"] }), + [] +)); + +// ═════════════════════════════════════════════════════════════════════════════ +// 4. batteryColorForLevel +// ═════════════════════════════════════════════════════════════════════════════ +group("batteryColorForLevel"); + +test("level 0 → error color (identity)", () => { + const c = batteryColorForLevel(0); + eq(c, ERROR); +}); + +test("level 15 → error color (boundary)", () => { + const c = batteryColorForLevel(15); + eq(c, ERROR); +}); + +test("level 16 → lerp starts (slightly above error)", () => { + const c = batteryColorForLevel(16); + const t = 1 / 25; + colourApprox(c, + ERROR.r + (TERTIARY.r - ERROR.r) * t, + ERROR.g + (TERTIARY.g - ERROR.g) * t, + ERROR.b + (TERTIARY.b - ERROR.b) * t, + "level16" + ); +}); + +test("level 40 → tertiary color (boundary)", () => { + const c = batteryColorForLevel(40); + // lerp(error, tertiary, (40-15)/25) = lerp(error, tertiary, 1) = tertiary + colourApprox(c, TERTIARY.r, TERTIARY.g, TERTIARY.b, "level40"); +}); + +test("level 41 → lerp starts between tertiary and primary", () => { + const c = batteryColorForLevel(41); + const t = 1 / 60; + colourApprox(c, + TERTIARY.r + (PRIMARY.r - TERTIARY.r) * t, + TERTIARY.g + (PRIMARY.g - TERTIARY.g) * t, + TERTIARY.b + (PRIMARY.b - TERTIARY.b) * t, + "level41" + ); +}); + +test("level 100 → primary color", () => { + const c = batteryColorForLevel(100); + colourApprox(c, PRIMARY.r, PRIMARY.g, PRIMARY.b, "level100"); +}); + +test("NaN input → error color (clamped to 0)", () => { + const c = batteryColorForLevel(NaN); + eq(c, ERROR); +}); + +test("negative input → clamped to 0 → error color", () => { + const c = batteryColorForLevel(-10); + eq(c, ERROR); +}); + +test("over-100 input → clamped to 100 → primary color", () => { + const c = batteryColorForLevel(150); + colourApprox(c, PRIMARY.r, PRIMARY.g, PRIMARY.b, "level150→100"); +}); + +test("string '50' → parsed to 50", () => { + const c50 = batteryColorForLevel(50); + const cStr = batteryColorForLevel("50"); + eq(c50, cStr); +}); + +test("level 28 → midpoint of error→tertiary lerp", () => { + const c = batteryColorForLevel(28); // t = (28-15)/25 = 0.52 + const t = 13 / 25; + colourApprox(c, + ERROR.r + (TERTIARY.r - ERROR.r) * t, + ERROR.g + (TERTIARY.g - ERROR.g) * t, + ERROR.b + (TERTIARY.b - ERROR.b) * t, + "level28" + ); +}); + +test("level 70 → midpoint of tertiary→primary lerp", () => { + const c = batteryColorForLevel(70); // t = (70-40)/60 = 0.5 + const t = 30 / 60; + colourApprox(c, + TERTIARY.r + (PRIMARY.r - TERTIARY.r) * t, + TERTIARY.g + (PRIMARY.g - TERTIARY.g) * t, + TERTIARY.b + (PRIMARY.b - TERTIARY.b) * t, + "level70" + ); +}); + +// ═════════════════════════════════════════════════════════════════════════════ +// Summary +// ═════════════════════════════════════════════════════════════════════════════ +const total = passed + failed; +console.log(`\n${"─".repeat(50)}`); +console.log(`${passed}/${total} passed${failed > 0 ? ` (${failed} FAILED)` : ""}`); +if (failed > 0) process.exit(1); diff --git a/tests/bbm/tst_BbmIcon.qml b/tests/bbm/tst_BbmIcon.qml new file mode 100644 index 0000000000..3211e341fb --- /dev/null +++ b/tests/bbm/tst_BbmIcon.qml @@ -0,0 +1,56 @@ +import QtQuick +import QtTest + +TestCase { + id: root + + function test_instantiates() { + var obj = createTemporaryObject(bbmIconComp, root); + verify(obj !== null, "BbmIcon should instantiate"); + } + + function test_default_size_18() { + var obj = createTemporaryObject(bbmIconComp, root); + compare(obj.size, 18); + compare(obj.implicitWidth, 18); + compare(obj.implicitHeight, 18); + } + + function test_custom_size() { + var obj = createTemporaryObject(bbmIconComp, root); + obj.size = 32; + compare(obj.size, 32); + compare(obj.implicitWidth, 32); + compare(obj.implicitHeight, 32); + } + + function test_url_property_settable() { + var obj = createTemporaryObject(bbmIconComp, root); + obj.url = "file:///some/path/icon.svg"; + compare(obj.url, "file:///some/path/icon.svg"); + } + + function test_colour_property_settable() { + var obj = createTemporaryObject(bbmIconComp, root); + obj.colour = Qt.rgba(1, 0, 0, 1); + // colour is a required property — just verify it doesn't crash + verify(obj.colour !== undefined); + } + + function test_empty_url_does_not_crash() { + var obj = createTemporaryObject(bbmIconComp, root); + obj.url = ""; + verify(obj !== null); + } + + name: "BbmIcon" + + Component { + id: bbmIconComp + + BbmIcon { + url: "" + colour: "white" + } + } +}