From a0de261c48ec74dababf9d5dd7537c4544944da0 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:01:11 +0530 Subject: [PATCH 01/18] feat(bluetooth): show BBM per-component battery and ANC controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates the Bluetooth Battery Meter daemon (org.bbm D-Bus service) into the Bluetooth popout. When the daemon is running and a supported device is connected, a collapsible row appears beneath the device entry showing: - Per-component battery bars (L / R / case) with SVG icons, percentage, and a bolt icon when charging - ANC / Noise Control toggle pills (Off / Transparency / NC) with the device's own icon alongside the label - NC intensity sub-row (Low / Mid / High / Adaptive) for devices that support multiple cancellation levels, using the secondary accent to visually distinguish it from the mode row The integration degrades gracefully: when the daemon is not running or the device is not supported the Bluetooth popout renders exactly as before. BbmService retries the D-Bus connection every 5 s so it recovers automatically if the daemon starts later. New files: services/BbmService.qml – singleton owning org.bbm session-bus connection; GVariant text parser; icon URL helpers (batteryIconUrl / toggleIconUrl) with encodeURI so paths containing spaces work modules/bar/popouts/BbmExpandedRow.qml – expanded device row; inline BbmSvgIcon component uses QtQuick. Effects MultiEffect for theme-aware recolouring of symbolic SVG icons Modified files: modules/bar/popouts/Bluetooth.qml – delegate changed from RowLayout to ColumnLayout; Loader added for BbmExpandedRow modules/ServiceLoader.qml – eagerly instantiates BbmService so battery data is ready before the user first opens the Bluetooth popout Requires: bbm-daemon ≥ commit that adds GetIconPath() and AncLevel* fields to the org.bbm.Manager D-Bus interface. Co-Authored-By: Claude Sonnet 4.6 --- modules/ServiceLoader.qml | 2 + modules/bar/popouts/BbmExpandedRow.qml | 230 ++++++++++++++++++++++++ modules/bar/popouts/Bluetooth.qml | 131 ++++++++------ services/BbmService.qml | 235 +++++++++++++++++++++++++ 4 files changed, 541 insertions(+), 57 deletions(-) create mode 100644 modules/bar/popouts/BbmExpandedRow.qml create mode 100644 services/BbmService.qml 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/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml new file mode 100644 index 0000000000..27029225a0 --- /dev/null +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -0,0 +1,230 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import QtQuick.Layouts +import Caelestia.Config +import qs.components +import qs.components.controls +import qs.services + +ColumnLayout { + id: root + + required property var bbmData + required property string address + + spacing: Tokens.spacing.extraSmall + Layout.fillWidth: true + Layout.topMargin: Tokens.spacing.extraSmall + + // ── Per-bud battery row ───────────────────────────────────────────────── + // Only rendered when we have two distinct earbuds (Battery2Level > 0). + + RowLayout { + visible: (root.bbmData?.Battery2Level ?? 0) > 0 + Layout.fillWidth: true + spacing: Tokens.spacing.small + + Repeater { + model: root.bbmData ? [ + {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, + {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, + {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, + ].filter(b => b.level > 0) : [] + + delegate: RowLayout { + required property var modelData + + spacing: Tokens.spacing.extraSmall + + // SVG battery-component icon (left bud, right bud, case) + BbmSvgIcon { + url: BbmService.batteryIconUrl(modelData.icon) + visible: url.length > 0 + size: 14 + colour: Colours.palette.m3onSurfaceVariant + } + + StyledProgressBar { + implicitWidth: 36 + implicitHeight: 4 + value: modelData.level / 100 + fgColour: modelData.level < 20 ? Colours.palette.m3error : Colours.palette.m3primary + } + + StyledText { + text: `${modelData.level}%` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + MaterialIcon { + visible: modelData.charging + text: "bolt" + font.pixelSize: 12 + color: Colours.palette.m3primary + } + } + } + } + + // ── Toggle rows (ANC / mode selector) ─────────────────────────────────── + + Repeater { + model: [ + {title: root.bbmData?.Toggle1Title ?? "", buttons: root.bbmData?.Toggle1Buttons ?? [], buttonIcons: root.bbmData?.Toggle1ButtonIcons ?? [], state: root.bbmData?.Toggle1State ?? 0, visible: root.bbmData?.Toggle1Visible ?? false, widgetId: "toggle1State"}, + {title: root.bbmData?.Toggle2Title ?? "", buttons: root.bbmData?.Toggle2Buttons ?? [], buttonIcons: root.bbmData?.Toggle2ButtonIcons ?? [], state: root.bbmData?.Toggle2State ?? 0, visible: root.bbmData?.Toggle2Visible ?? false, widgetId: "toggle2State"}, + ].filter(t => t.visible && t.buttons.length > 0) + + 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 + } + + RowLayout { + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + // Build a richer model that bundles the icon name into each button entry + 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: pillContent.implicitHeight + Tokens.padding.extraSmall * 2 + implicitWidth: pillContent.implicitWidth + Tokens.padding.small * 2 + + radius: Tokens.rounding.full + color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + StateLayer { + color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(root.address, pill.modelData.widgetId, pill.modelData.index + 1) + } + + RowLayout { + id: pillContent + anchors.centerIn: parent + spacing: Tokens.spacing.extraSmall + + // SVG toggle icon alongside text label + BbmSvgIcon { + url: BbmService.toggleIconUrl(pill.modelData.icon) + visible: url.length > 0 + size: 14 + colour: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + } + + StyledText { + text: pill.modelData.name + color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface + font: Tokens.font.body.small + } + } + } + } + } + } + } + + // ── ANC level sub-row (box1RadioButton / box1RadioButtonState) ─────────── + // Shown when the device supports multiple NC intensity levels. + + ColumnLayout { + visible: (root.bbmData?.AncLevelButtons ?? []).length > 0 + Layout.fillWidth: true + spacing: Tokens.spacing.extraSmall + + 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.extraSmall * 2 + implicitWidth: levelLabel.implicitWidth + Tokens.padding.small * 2 + + radius: Tokens.rounding.full + color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + + StateLayer { + color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface + onClicked: BbmService.sendUIAction(root.address, "box1RadioButtonState", 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 + } + } + } + } + } + + // ── Inline helper component — SVG icon with theme-color overlay ────────── + + component BbmSvgIcon: Item { + id: iconItem + + required property string url + required property color colour + property int size: 16 + + implicitWidth: size + implicitHeight: size + + Image { + id: iconImg + anchors.fill: parent + source: iconItem.url + sourceSize: Qt.size(iconItem.size, iconItem.size) + fillMode: Image.PreserveAspectFit + smooth: true + layer.enabled: true + layer.effect: MultiEffect { + colorization: 1.0 + colorizationColor: iconItem.colour + } + } + } +} diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index ebf21de30e..34c582319d 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -65,7 +65,7 @@ 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 @@ -73,7 +73,7 @@ ColumnLayout { Layout.fillWidth: true Layout.rightMargin: Tokens.padding.extraSmall - spacing: Tokens.spacing.small + spacing: 0 opacity: 0 scale: 0.7 @@ -93,81 +93,98 @@ 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 - } - - 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 - } + spacing: Tokens.spacing.small - 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 + MaterialIcon { + text: Icons.getBluetoothIcon(device.modelData.icon) } - 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 + StyledText { + Layout.leftMargin: Tokens.spacing.extraSmall + Layout.rightMargin: Tokens.spacing.extraSmall + Layout.fillWidth: true + text: device.modelData.name + elide: Text.ElideRight } MaterialIcon { - id: connectIcon + 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 + } - 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 + StyledRect { + id: connectBtn - opacity: device.loading ? 0 : 1 + implicitWidth: implicitHeight + implicitHeight: connectIcon.implicitHeight + Tokens.padding.extraSmall - Behavior on opacity { - Anim { - type: Anim.DefaultEffects - } - } - } - } + radius: Tokens.rounding.full + color: Qt.alpha(Colours.palette.m3primary, device.modelData.state === BluetoothDeviceState.Connected ? 1 : 0) // qmllint disable unresolved-type - Loader { - visible: status === Loader.Ready - asynchronous: true - active: device.modelData.bonded - sourceComponent: Item { - implicitWidth: connectBtn.implicitWidth - implicitHeight: connectBtn.implicitHeight + 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 + } + } + } + } + + 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.modelData.connected + && BbmService.available + && BbmService.dataFor(device.modelData.address) !== null + Layout.fillWidth: true + sourceComponent: BbmExpandedRow { + bbmData: BbmService.dataFor(device.modelData.address) + address: device.modelData.address + } + } } } diff --git a/services/BbmService.qml b/services/BbmService.qml new file mode 100644 index 0000000000..59e0980596 --- /dev/null +++ b/services/BbmService.qml @@ -0,0 +1,235 @@ +pragma Singleton + +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + // 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"); + } + + function sendUIAction(address, widgetId, value) { + sendActionComp.createObject(root, { + command: ["gdbus", "call", "--session", "--dest", "org.bbm", + "--object-path", "/org/bbm", "--method", "org.bbm.Manager.SendUIAction", + `'${address}'`, `'${widgetId}'`, String(value)] + }).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 { + const n = parseInt(raw, 10); + result[key] = isNaN(n) ? raw : n; + } + } + return result; + } + + // ── Process components ──────────────────────────────────────────────────── + + // Dynamic: one instance per GetDeviceData call, self-destroys on exit. + Component { + id: fetchDeviceComp + Process { + property string addr: "" + command: ["gdbus", "call", "--session", "--dest", "org.bbm", + "--object-path", "/org/bbm", "--method", "org.bbm.Manager.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", "org.bbm", + "--object-path", "/org/bbm", "--method", "org.bbm.Manager.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 + command: ["gdbus", "monitor", "--session", "--dest", "org.bbm", + "--object-path", "/org/bbm"] + stdout: SplitParser { + onRead: line => { + const m = line.match(/org\.bbm\.Manager\.(\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", "org.bbm", + "--object-path", "/org/bbm", "--method", "org.bbm.Manager.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(); + } + } + } + + // ── Internal helpers ────────────────────────────────────────────────────── + + function _fetchDevice(address) { + fetchDeviceComp.createObject(root, {addr: address}).running = true; + } + + function _init() { + if (!monitor.running) + monitor.running = true; + if (!listProc.running) + listProc.running = true; + if (!iconPathProc.running) + iconPathProc.running = true; + } + + Timer { + id: retryTimer + interval: 5000 + repeat: false + onTriggered: root._init() + } + + Component.onCompleted: _init() +} From 7e96d6bd16856960eba1d21902b1de903a9449f5 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:58:07 +0530 Subject: [PATCH 02/18] BbmExpandedRow: icon-only ANC pills, animated NC levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ANC toggle pills are now icon-only (no text label) with Tokens.rounding.medium radius so they look like rounded-square chips rather than full pills - NC intensity level pills also use Tokens.rounding.medium for visual consistency - NC level section animates in/out (scale 0.92→1 + opacity 0→1) and is only shown when OptionsBoxVisible === 1 (i.e. NC mode is the active ANC state) - Card background uses StyledRect with m3surfaceContainer + Tokens.rounding.large - All icons theme-aware via MultiEffect.colorization: 1.0 Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 317 ++++++++++++++----------- 1 file changed, 183 insertions(+), 134 deletions(-) diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index 27029225a0..b754a6c5ac 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -8,202 +8,252 @@ import qs.components import qs.components.controls import qs.services -ColumnLayout { +StyledRect { id: root required property var bbmData required property string address - spacing: Tokens.spacing.extraSmall Layout.fillWidth: true + Layout.rightMargin: Tokens.padding.extraSmall Layout.topMargin: Tokens.spacing.extraSmall - // ── Per-bud battery row ───────────────────────────────────────────────── - // Only rendered when we have two distinct earbuds (Battery2Level > 0). + radius: Tokens.rounding.large + color: Colours.tPalette.m3surfaceContainer + clip: true + implicitHeight: content.implicitHeight + Tokens.padding.large * 2 - RowLayout { - visible: (root.bbmData?.Battery2Level ?? 0) > 0 - Layout.fillWidth: true + 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 - Repeater { - model: root.bbmData ? [ - {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, - {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, - {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, - ].filter(b => b.level > 0) : [] + // ── Per-bud battery row ─────────────────────────────────────────────── - delegate: RowLayout { - required property var modelData + RowLayout { + visible: (root.bbmData?.Battery2Level ?? 0) > 0 + Layout.fillWidth: true + spacing: Tokens.spacing.medium - spacing: Tokens.spacing.extraSmall + Repeater { + model: root.bbmData ? [ + {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, + {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, + {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, + ].filter(b => b.level > 0) : [] - // SVG battery-component icon (left bud, right bud, case) - BbmSvgIcon { - url: BbmService.batteryIconUrl(modelData.icon) - visible: url.length > 0 - size: 14 - colour: Colours.palette.m3onSurfaceVariant - } + delegate: RowLayout { + required property var modelData - StyledProgressBar { - implicitWidth: 36 - implicitHeight: 4 - value: modelData.level / 100 - fgColour: modelData.level < 20 ? Colours.palette.m3error : Colours.palette.m3primary - } + spacing: Tokens.spacing.extraSmall - StyledText { - text: `${modelData.level}%` - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } + BbmIcon { + url: BbmService.batteryIconUrl(modelData.icon) + visible: url.length > 0 + size: 14 + colour: Colours.palette.m3onSurfaceVariant + } + + StyledProgressBar { + implicitWidth: 32 + implicitHeight: 4 + value: modelData.level / 100 + fgColour: modelData.level < 20 ? Colours.palette.m3error : Colours.palette.m3primary + } - MaterialIcon { - visible: modelData.charging - text: "bolt" - font.pixelSize: 12 - color: Colours.palette.m3primary + StyledText { + text: `${modelData.level}%` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } + + MaterialIcon { + visible: modelData.charging + text: "bolt" + font.pixelSize: 12 + color: Colours.palette.m3primary + } } } } - } - - // ── Toggle rows (ANC / mode selector) ─────────────────────────────────── - - Repeater { - model: [ - {title: root.bbmData?.Toggle1Title ?? "", buttons: root.bbmData?.Toggle1Buttons ?? [], buttonIcons: root.bbmData?.Toggle1ButtonIcons ?? [], state: root.bbmData?.Toggle1State ?? 0, visible: root.bbmData?.Toggle1Visible ?? false, widgetId: "toggle1State"}, - {title: root.bbmData?.Toggle2Title ?? "", buttons: root.bbmData?.Toggle2Buttons ?? [], buttonIcons: root.bbmData?.Toggle2ButtonIcons ?? [], state: root.bbmData?.Toggle2State ?? 0, visible: root.bbmData?.Toggle2Visible ?? false, widgetId: "toggle2State"}, - ].filter(t => t.visible && t.buttons.length > 0) - delegate: ColumnLayout { - id: toggleRow + // ── ANC / mode toggle (icon-only segmented control) ─────────────────── - required property var modelData + Repeater { + model: [ + {title: root.bbmData?.Toggle1Title ?? "", buttons: root.bbmData?.Toggle1Buttons ?? [], buttonIcons: root.bbmData?.Toggle1ButtonIcons ?? [], state: root.bbmData?.Toggle1State ?? 0, visible: root.bbmData?.Toggle1Visible ?? false, widgetId: "toggle1State"}, + {title: root.bbmData?.Toggle2Title ?? "", buttons: root.bbmData?.Toggle2Buttons ?? [], buttonIcons: root.bbmData?.Toggle2ButtonIcons ?? [], state: root.bbmData?.Toggle2State ?? 0, visible: root.bbmData?.Toggle2Visible ?? false, widgetId: "toggle2State"}, + ].filter(t => t.visible && t.buttons.length > 0) - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall + delegate: ColumnLayout { + id: toggleRow - StyledText { - visible: toggleRow.modelData.title.length > 0 - text: toggleRow.modelData.title - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } + required property var modelData - RowLayout { Layout.fillWidth: true spacing: Tokens.spacing.extraSmall - // Build a richer model that bundles the icon name into each button entry - 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, - })) + 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 - delegate: StyledRect { - id: pill + 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, + })) - required property var modelData + delegate: StyledRect { + id: pill - implicitHeight: pillContent.implicitHeight + Tokens.padding.extraSmall * 2 - implicitWidth: pillContent.implicitWidth + Tokens.padding.small * 2 + required property var modelData - radius: Tokens.rounding.full - color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + // Square-ish pill: equal height/width padding so icon is centred + implicitHeight: 14 + Tokens.padding.small * 2 + implicitWidth: 14 + Tokens.padding.medium * 2 - StateLayer { - color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - onClicked: BbmService.sendUIAction(root.address, pill.modelData.widgetId, pill.modelData.index + 1) - } + radius: Tokens.rounding.medium + color: pill.modelData.active + ? Colours.palette.m3primary + : Qt.alpha(Colours.palette.m3onSurface, 0.08) - RowLayout { - id: pillContent - anchors.centerIn: parent - spacing: Tokens.spacing.extraSmall + Behavior on color { + Anim { type: Anim.DefaultEffects } + } - // SVG toggle icon alongside text label - BbmSvgIcon { + 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 { + anchors.centerIn: parent url: BbmService.toggleIconUrl(pill.modelData.icon) + // Fall back to nothing if no icon (pill colour still shows active state) visible: url.length > 0 size: 14 colour: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface } - - StyledText { - text: pill.modelData.name - color: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface - font: Tokens.font.body.small - } } } } } } - } - // ── ANC level sub-row (box1RadioButton / box1RadioButtonState) ─────────── - // Shown when the device supports multiple NC intensity levels. + // ── NC intensity levels — pops in only when NC mode is active ───────── - ColumnLayout { - visible: (root.bbmData?.AncLevelButtons ?? []).length > 0 - Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall - - StyledText { - visible: (root.bbmData?.AncLevelTitle ?? "").length > 0 - text: root.bbmData?.AncLevelTitle ?? "" - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } + Item { + id: ancLevelContainer + + readonly property bool showing: (root.bbmData?.OptionsBoxVisible ?? 0) === 1 + && (root.bbmData?.AncLevelButtons ?? []).length > 0 - RowLayout { Layout.fillWidth: true - spacing: Tokens.spacing.extraSmall + implicitHeight: showing ? ancLevelLayout.implicitHeight : 0 + clip: true - Repeater { - model: (root.bbmData?.AncLevelButtons ?? []).map((name, i) => ({ - name, - active: (root.bbmData?.AncLevelState ?? 0) === (i + 1), - index: i, - })) + Behavior on implicitHeight { + Anim {} + } - delegate: StyledRect { - id: levelPill + ColumnLayout { + id: ancLevelLayout - required property var modelData + width: parent.width + spacing: Tokens.spacing.extraSmall - implicitHeight: levelLabel.implicitHeight + Tokens.padding.extraSmall * 2 - implicitWidth: levelLabel.implicitWidth + Tokens.padding.small * 2 + opacity: ancLevelContainer.showing ? 1 : 0 + scale: ancLevelContainer.showing ? 1 : 0.92 + transformOrigin: Item.Top - radius: Tokens.rounding.full - color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) + Behavior on opacity { + Anim { type: Anim.StandardSmall } + } - StateLayer { - color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface - onClicked: BbmService.sendUIAction(root.address, "box1RadioButtonState", levelPill.modelData.index + 1) - } + Behavior on scale { + Anim { type: Anim.StandardSmall } + } - 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 + 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, "box1RadioButtonState", 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 + } + } } } } } } - // ── Inline helper component — SVG icon with theme-color overlay ────────── + // ── Theme-aware SVG icon — alpha mask coloured by the theme token ───────── - component BbmSvgIcon: Item { + component BbmIcon: Item { id: iconItem required property string url @@ -214,7 +264,6 @@ ColumnLayout { implicitHeight: size Image { - id: iconImg anchors.fill: parent source: iconItem.url sourceSize: Qt.size(iconItem.size, iconItem.size) From a13f9a6e036afcbb9d49c58277d3214bc1cf134e Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:56:41 +0530 Subject: [PATCH 03/18] BbmExpandedRow: fix icon colours + wider popout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch BbmIcon from raw MultiEffect to Colouriser (which applies brightness pre-correction based on sourceColor.hslLightness) so dark SVG fills (#2e3436) are normalised to near-white before the colorizationColor is applied. Without this, dark source pixels stayed dark regardless of the theme token. - Battery icons: 16px, m3onSurface colour (correctly follows light/dark theme) - Toggle pill icons: 18px, same colour semantics - Pill size grows to accommodate 18px icons with padding - Bluetooth popout width: 300 → 360 to fit Low/Mid/High/Adaptive row Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 25 ++++++++++++++----------- modules/bar/popouts/Bluetooth.qml | 2 +- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index b754a6c5ac..928c0e3e65 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -1,11 +1,11 @@ pragma ComponentBehavior: Bound import QtQuick -import QtQuick.Effects import QtQuick.Layouts import Caelestia.Config import qs.components import qs.components.controls +import qs.components.effects import qs.services StyledRect { @@ -60,8 +60,8 @@ StyledRect { BbmIcon { url: BbmService.batteryIconUrl(modelData.icon) visible: url.length > 0 - size: 14 - colour: Colours.palette.m3onSurfaceVariant + size: 16 + colour: Colours.palette.m3onSurface } StyledProgressBar { @@ -130,8 +130,8 @@ StyledRect { required property var modelData // Square-ish pill: equal height/width padding so icon is centred - implicitHeight: 14 + Tokens.padding.small * 2 - implicitWidth: 14 + Tokens.padding.medium * 2 + implicitHeight: 18 + Tokens.padding.small * 2 + implicitWidth: 18 + Tokens.padding.medium * 2 radius: Tokens.rounding.medium color: pill.modelData.active @@ -151,9 +151,8 @@ StyledRect { BbmIcon { anchors.centerIn: parent url: BbmService.toggleIconUrl(pill.modelData.icon) - // Fall back to nothing if no icon (pill colour still shows active state) visible: url.length > 0 - size: 14 + size: 18 colour: pill.modelData.active ? Colours.palette.m3onPrimary : Colours.palette.m3onSurface } } @@ -251,14 +250,18 @@ StyledRect { } } - // ── Theme-aware SVG icon — alpha mask coloured by the theme token ───────── + // ── Theme-aware SVG icon ────────────────────────────────────────────────── + // BBM SVGs have fill="#2e3436" (dark). Colouriser brightens by + // (1 - sourceColor.hslLightness) so the dark pixels normalise to near-white + // before colorizationColor is applied — the icon then takes on the exact + // theme token colour regardless of light/dark mode. component BbmIcon: Item { id: iconItem required property string url required property color colour - property int size: 16 + property int size: 18 implicitWidth: size implicitHeight: size @@ -270,8 +273,8 @@ StyledRect { fillMode: Image.PreserveAspectFit smooth: true layer.enabled: true - layer.effect: MultiEffect { - colorization: 1.0 + layer.effect: Colouriser { + sourceColor: "#2e3436" colorizationColor: iconItem.colour } } diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 34c582319d..3594044dc1 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -15,7 +15,7 @@ ColumnLayout { required property PopoutState popouts - width: 300 + width: 360 spacing: Tokens.spacing.small StyledText { From a30e29e01f6a8faff60433cd86acff41c5bb2b68 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:05:03 +0530 Subject: [PATCH 04/18] BbmExpandedRow: universality fixes + 2px panel margins - Battery row now shows when Battery1Level > 0 (was Battery2Level > 0), so single-battery devices (headphones, neckbands, ear sticks) display their battery component instead of a blank row - BbmIcon hides itself on Image.Error so a missing/broken SVG path never shows Qt's broken-image placeholder (fixes stray '+' artifact) - Remove redundant Layout.rightMargin from BbmExpandedRow; parent device ColumnLayout now owns 2px horizontal margins on both sides - Bluetooth.qml device delegate: Layout.leftMargin/rightMargin = 2px each so the device list has 2px breathing room from the panel edges Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 5 +++-- modules/bar/popouts/Bluetooth.qml | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index 928c0e3e65..5059caccd8 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -15,7 +15,6 @@ StyledRect { required property string address Layout.fillWidth: true - Layout.rightMargin: Tokens.padding.extraSmall Layout.topMargin: Tokens.spacing.extraSmall radius: Tokens.rounding.large @@ -41,7 +40,7 @@ StyledRect { // ── Per-bud battery row ─────────────────────────────────────────────── RowLayout { - visible: (root.bbmData?.Battery2Level ?? 0) > 0 + visible: (root.bbmData?.Battery1Level ?? 0) > 0 Layout.fillWidth: true spacing: Tokens.spacing.medium @@ -267,11 +266,13 @@ StyledRect { implicitHeight: size Image { + id: img anchors.fill: parent source: iconItem.url sourceSize: Qt.size(iconItem.size, iconItem.size) fillMode: Image.PreserveAspectFit smooth: true + visible: status !== Image.Error layer.enabled: true layer.effect: Colouriser { sourceColor: "#2e3436" diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 3594044dc1..088fa4e1a8 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -72,7 +72,8 @@ ColumnLayout { readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type Layout.fillWidth: true - Layout.rightMargin: Tokens.padding.extraSmall + Layout.leftMargin: 2 + Layout.rightMargin: 2 spacing: 0 opacity: 0 From 0f5b7a5e8e7ddd199300a8886ff20dde0879f148 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:09:09 +0530 Subject: [PATCH 05/18] BbmIcon: dynamic source colour via ImageAnalyser Replace hardcoded sourceColor: "#2e3436" with ImageAnalyser.dominantColour, mirroring ColouredIcon.qml. The analyser reads the actual rendered pixels to derive the dominant source colour, so Colouriser's brightness compensation is always accurate regardless of which BBM SVG variant is loaded. Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index 5059caccd8..284d3adfa0 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound import QtQuick import QtQuick.Layouts +import Caelestia import Caelestia.Config import qs.components import qs.components.controls @@ -250,10 +251,9 @@ StyledRect { } // ── Theme-aware SVG icon ────────────────────────────────────────────────── - // BBM SVGs have fill="#2e3436" (dark). Colouriser brightens by - // (1 - sourceColor.hslLightness) so the dark pixels normalise to near-white - // before colorizationColor is applied — the icon then takes on the exact - // theme token colour regardless of light/dark mode. + // Mirrors ColouredIcon.qml: ImageAnalyser reads the dominant colour from + // the rendered image so Colouriser can compensate for any source luminosity + // dynamically — no hardcoded fill value, works across all BBM SVG variants. component BbmIcon: Item { id: iconItem @@ -272,12 +272,29 @@ StyledRect { 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: "#2e3436" + 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 } } } From 744bb34791105f1dc1fb4c8831244051973d12a3 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:13:06 +0530 Subject: [PATCH 06/18] ponytail-audit: eliminate hardcoded values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BbmExpandedRow.qml: - Pill implicitHeight/implicitWidth now derived from pillIcon.size instead of repeating the magic number 18 in three places - Remove font.pixelSize: 12 from bolt MaterialIcon (inherits from context) BbmService.qml: - Extract D-Bus dest/path/interface to readonly property constants (_dest, _path, _iface) and reference them across all 5 gdbus commands and the signal monitor regex — single edit point if the bus name ever changes Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 7 +++---- services/BbmService.qml | 27 +++++++++++++++----------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index 284d3adfa0..7191948e1e 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -80,7 +80,6 @@ StyledRect { MaterialIcon { visible: modelData.charging text: "bolt" - font.pixelSize: 12 color: Colours.palette.m3primary } } @@ -129,9 +128,8 @@ StyledRect { required property var modelData - // Square-ish pill: equal height/width padding so icon is centred - implicitHeight: 18 + Tokens.padding.small * 2 - implicitWidth: 18 + Tokens.padding.medium * 2 + implicitHeight: pillIcon.size + Tokens.padding.small * 2 + implicitWidth: pillIcon.size + Tokens.padding.medium * 2 radius: Tokens.rounding.medium color: pill.modelData.active @@ -149,6 +147,7 @@ StyledRect { } BbmIcon { + id: pillIcon anchors.centerIn: parent url: BbmService.toggleIconUrl(pill.modelData.icon) visible: url.length > 0 diff --git a/services/BbmService.qml b/services/BbmService.qml index 59e0980596..8c677ddbf3 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -7,6 +7,10 @@ import Quickshell.Io Singleton { id: root + readonly property string _dest: "org.bbm" + readonly property string _path: "/org/bbm" + readonly property string _iface: "org.bbm.Manager" + // True when the org.bbm daemon is reachable on the session bus. property bool available: false @@ -39,8 +43,8 @@ Singleton { function sendUIAction(address, widgetId, value) { sendActionComp.createObject(root, { - command: ["gdbus", "call", "--session", "--dest", "org.bbm", - "--object-path", "/org/bbm", "--method", "org.bbm.Manager.SendUIAction", + command: ["gdbus", "call", "--session", "--dest", root._dest, + "--object-path", root._path, "--method", `${root._iface}.SendUIAction`, `'${address}'`, `'${widgetId}'`, String(value)] }).running = true; } @@ -117,8 +121,8 @@ Singleton { id: fetchDeviceComp Process { property string addr: "" - command: ["gdbus", "call", "--session", "--dest", "org.bbm", - "--object-path", "/org/bbm", "--method", "org.bbm.Manager.GetDeviceData", + command: ["gdbus", "call", "--session", "--dest", root._dest, + "--object-path", root._path, "--method", `${root._iface}.GetDeviceData`, `'${addr}'`] stdout: StdioCollector { onStreamFinished: { @@ -146,8 +150,8 @@ Singleton { Process { id: iconPathProc running: false - command: ["gdbus", "call", "--session", "--dest", "org.bbm", - "--object-path", "/org/bbm", "--method", "org.bbm.Manager.GetIconPath"] + command: ["gdbus", "call", "--session", "--dest", root._dest, + "--object-path", root._path, "--method", `${root._iface}.GetIconPath`] stdout: StdioCollector { onStreamFinished: { // Output format: ('/path/to/icons',)\n @@ -163,11 +167,12 @@ Singleton { Process { id: monitor running: false - command: ["gdbus", "monitor", "--session", "--dest", "org.bbm", - "--object-path", "/org/bbm"] + command: ["gdbus", "monitor", "--session", "--dest", root._dest, + "--object-path", root._path] stdout: SplitParser { onRead: line => { - const m = line.match(/org\.bbm\.Manager\.(\w+) \('([^']+)',\)/); + const ifaceEsc = root._iface.replace(/\./g, "\\."); + const m = line.match(new RegExp(ifaceEsc + "\\.(\\w+) \\('([^']+)',\\)")); if (!m) return; const [, signal, address] = m; @@ -191,8 +196,8 @@ Singleton { Process { id: listProc running: false - command: ["gdbus", "call", "--session", "--dest", "org.bbm", - "--object-path", "/org/bbm", "--method", "org.bbm.Manager.GetDevices"] + command: ["gdbus", "call", "--session", "--dest", root._dest, + "--object-path", root._path, "--method", `${root._iface}.GetDevices`] stdout: StdioCollector { onStreamFinished: { if (!text.trim().startsWith("(")) From a3faae7624056276cea236ef52efbded8237244a Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:18:10 +0530 Subject: [PATCH 07/18] nix: add glib to runtimeDeps for gdbus BbmService.qml spawns gdbus (from glib) to communicate with the org.bbm D-Bus service. Declare it explicitly so the Nix-wrapped binary has it in PATH on NixOS. callPackage injects pkgs.glib automatically by name. Co-Authored-By: Claude Sonnet 4.6 --- nix/default.nix | 2 ++ 1 file changed, 2 insertions(+) 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 From 317473e6f139cda91580970d33517eb10a2bde1b Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:19:47 +0530 Subject: [PATCH 08/18] =?UTF-8?q?Bluetooth:=20reduce=20popout=20width=2036?= =?UTF-8?q?0=20=E2=86=92=20320?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/Bluetooth.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 088fa4e1a8..2c00721cde 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -15,7 +15,7 @@ ColumnLayout { required property PopoutState popouts - width: 360 + width: 320 spacing: Tokens.spacing.small StyledText { From def987e21766021a849080a355856276c253b26b Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:39:38 +0530 Subject: [PATCH 09/18] Integrate BBM daemon into caelestia-shell with Nix home-manager support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bundle bbm/daemon + bbm/lib + bbm/icons from Bluetooth-Battery-Meter - Add nix/bbm.nix derivation: compiles schemas, wraps gjs as bbm-daemon binary - Expose bbm-daemon package in flake.nix - Add programs.caelestia.bbm option to hm-module.nix with auto-starting systemd user service (After=bluetooth.target, PartOf=graphical-session) - CMakeLists: install bbm/ alongside shell QML dirs for non-Nix installs - BbmService.qml: fix _parseDeviceData to handle GVariant type-annotated integers ("int32 65", "uint32 3", etc.) — battery levels and toggle states were stored as raw strings, breaking level filters and state comparisons - Bluetooth.qml: add matching leftMargin=extraSmall wherever rightMargin was set, making horizontal padding symmetric Co-Authored-By: Claude Sonnet 4.6 --- CMakeLists.txt | 2 +- bbm/daemon/bbm-daemon.service | 17 + bbm/daemon/bluezEnumerator.js | 206 +++ bbm/daemon/dbusService.js | 218 +++ bbm/daemon/deviceRecord.js | 98 + bbm/daemon/main.js | 42 + bbm/daemon/notifier.js | 64 + bbm/daemon/orchestrator.js | 211 +++ bbm/daemon/schemas/org.bbm.gschema.xml | 75 + bbm/daemon/settings.js | 167 ++ .../actions/bbm-adaptive-symbolic.svg | 4 + .../actions/bbm-add-toggle-symbolic.svg | 1 + .../actions/bbm-added-toggle-symbolic.svg | 1 + .../scalable/actions/bbm-anc-off-symbolic.svg | 7 + .../scalable/actions/bbm-anc-on-symbolic.svg | 4 + .../actions/bbm-anc-wind-symbolic.svg | 1 + .../scalable/actions/bbm-art-audio-card.png | Bin 0 -> 11946 bytes .../actions/bbm-art-audio-headphones.png | Bin 0 -> 27836 bytes .../actions/bbm-art-audio-headset.png | Bin 0 -> 30817 bytes .../actions/bbm-art-audio-speakers.png | Bin 0 -> 12897 bytes .../actions/bbm-art-audio-speakers2.png | Bin 0 -> 11158 bytes .../actions/bbm-art-audio-speakers3.png | Bin 0 -> 11158 bytes .../scalable/actions/bbm-art-bluetooth.png | Bin 0 -> 25129 bytes .../scalable/actions/bbm-art-camera-photo.png | Bin 0 -> 11168 bytes .../scalable/actions/bbm-art-camera-video.png | Bin 0 -> 8719 bytes .../scalable/actions/bbm-art-computer.png | Bin 0 -> 7373 bytes .../scalable/actions/bbm-art-earbuds-bean.png | Bin 0 -> 27585 bytes .../actions/bbm-art-earbuds-neckband.png | Bin 0 -> 20815 bytes .../scalable/actions/bbm-art-earbuds-stem.png | Bin 0 -> 20088 bytes .../actions/bbm-art-earbuds-stem2.png | Bin 0 -> 21327 bytes .../actions/bbm-art-earbuds-stem3.png | Bin 0 -> 10558 bytes .../actions/bbm-art-earbuds-wingtip.png | Bin 0 -> 25607 bytes .../actions/bbm-art-earbuds-wingtip2.png | Bin 0 -> 30757 bytes .../scalable/actions/bbm-art-earbuds.png | Bin 0 -> 25513 bytes .../scalable/actions/bbm-art-headphone1.png | Bin 0 -> 27836 bytes .../scalable/actions/bbm-art-input-gaming.png | Bin 0 -> 25219 bytes .../actions/bbm-art-input-gaming2.png | Bin 0 -> 25219 bytes .../actions/bbm-art-input-gaming3.png | Bin 0 -> 25219 bytes .../actions/bbm-art-input-keyboard.png | Bin 0 -> 29816 bytes .../actions/bbm-art-input-keyboard2.png | Bin 0 -> 29816 bytes .../actions/bbm-art-input-microphone.png | Bin 0 -> 14328 bytes .../scalable/actions/bbm-art-input-mouse.png | Bin 0 -> 9577 bytes .../actions/bbm-art-input-split-keyboard.png | Bin 0 -> 20620 bytes .../scalable/actions/bbm-art-input-tablet.png | Bin 0 -> 7400 bytes .../scalable/actions/bbm-art-modem.png | Bin 0 -> 6587 bytes .../actions/bbm-art-multimedia-player.png | Bin 0 -> 13414 bytes .../actions/bbm-art-network-wireless.png | Bin 0 -> 6576 bytes .../hicolor/scalable/actions/bbm-art-pda.png | Bin 0 -> 9329 bytes .../actions/bbm-art-phone-apple-iphone.png | Bin 0 -> 8003 bytes .../bbm-art-phone-google-nexus-one.png | Bin 0 -> 8003 bytes .../bbm-art-phone-samsung-galaxy-s.png | Bin 0 -> 8003 bytes .../scalable/actions/bbm-art-phone.png | Bin 0 -> 8003 bytes .../scalable/actions/bbm-art-printer.png | Bin 0 -> 10676 bytes .../scalable/actions/bbm-art-scanner.png | Bin 0 -> 10903 bytes .../scalable/actions/bbm-art-touchpad.png | Bin 0 -> 7162 bytes .../actions/bbm-art-upower-devices.png | Bin 0 -> 23439 bytes .../actions/bbm-art-video-display.png | Bin 0 -> 6153 bytes .../scalable/actions/bbm-art-wearable.png | Bin 0 -> 10382 bytes .../scalable/actions/bbm-art-wearable2.png | Bin 0 -> 14778 bytes .../actions/bbm-audio-card-symbolic.svg | 1 + .../actions/bbm-audio-headphones-symbolic.svg | 1 + .../actions/bbm-audio-headset-symbolic.svg | 1 + .../actions/bbm-audio-speakers-symbolic.svg | 1 + .../actions/bbm-audio-speakers2-symbolic.svg | 1 + .../actions/bbm-audio-speakers3-symbolic.svg | 1 + .../actions/bbm-battery-widget-symbolic.svg | 1 + .../scalable/actions/bbm-bell-symbolic.svg | 1 + .../bbm-bluetooth-connected-symbolic.svg | 1 + .../bbm-bluetooth-connecting-1-symbolic.svg | 1 + .../bbm-bluetooth-connecting-2-symbolic.svg | 1 + .../bbm-bluetooth-connecting-3-symbolic.svg | 1 + .../bbm-bluetooth-connecting-4-symbolic.svg | 1 + ...bbm-bluetooth-disconnecting-1-symbolic.svg | 1 + ...bbm-bluetooth-disconnecting-2-symbolic.svg | 1 + ...bbm-bluetooth-disconnecting-3-symbolic.svg | 1 + ...bbm-bluetooth-disconnecting-4-symbolic.svg | 1 + .../actions/bbm-bluetooth-gui-symbolic.svg | 1 + .../actions/bbm-bluetooth-symbolic.svg | 3 + .../scalable/actions/bbm-bug-symbolic.svg | 1 + .../scalable/actions/bbm-bypass-symbolic.svg | 1 + .../scalable/actions/bbm-ca-off-symbolic.svg | 7 + .../scalable/actions/bbm-ca-on-symbolic.svg | 4 + .../actions/bbm-camera-photo-symbolic.svg | 1 + .../actions/bbm-camera-video-symbolic.svg | 1 + .../actions/bbm-case-narrow-symbolic.svg | 1 + .../actions/bbm-case-normal-symbolic.svg | 1 + .../actions/bbm-case-oval-short-symbolic.svg | 1 + .../actions/bbm-case-oval-symbolic.svg | 1 + .../actions/bbm-case-round-symbolic.svg | 1 + .../actions/bbm-case-square-symbolic.svg | 1 + .../scalable/actions/bbm-check-symbolic.svg | 1 + .../scalable/actions/bbm-color-symbolic.svg | 1 + .../actions/bbm-computer-symbolic.svg | 1 + .../actions/bbm-dark-mode-symbolic.svg | 1 + .../scalable/actions/bbm-db-symbolic.svg | 1 + .../actions/bbm-device-mgmt-symbolic.svg | 1 + .../bbm-earbuds-bean-left-symbolic.svg | 1 + .../bbm-earbuds-bean-right-symbolic.svg | 1 + .../actions/bbm-earbuds-bean-symbolic.svg | 1 + .../actions/bbm-earbuds-left-symbolic.svg | 1 + .../actions/bbm-earbuds-neckband-symbolic.svg | 1 + .../actions/bbm-earbuds-right-symbolic.svg | 1 + .../bbm-earbuds-stem-left-symbolic.svg | 1 + .../bbm-earbuds-stem-right-symbolic.svg | 1 + .../actions/bbm-earbuds-stem-symbolic.svg | 1 + .../bbm-earbuds-stem2-left-symbolic.svg | 1 + .../bbm-earbuds-stem2-right-symbolic.svg | 1 + .../actions/bbm-earbuds-stem2-symbolic.svg | 1 + .../bbm-earbuds-stem3-left-symbolic.svg | 1 + .../bbm-earbuds-stem3-right-symbolic.svg | 1 + .../actions/bbm-earbuds-stem3-symbolic.svg | 1 + .../scalable/actions/bbm-earbuds-symbolic.svg | 1 + .../bbm-earbuds-wingtip-left-symbolic.svg | 1 + .../bbm-earbuds-wingtip-right-symbolic.svg | 1 + .../actions/bbm-earbuds-wingtip-symbolic.svg | 1 + .../bbm-earbuds-wingtip2-left-symbolic.svg | 1 + .../bbm-earbuds-wingtip2-right-symbolic.svg | 1 + .../actions/bbm-earbuds-wingtip2-symbolic.svg | 1 + .../scalable/actions/bbm-edit-symbolic.svg | 1 + .../actions/bbm-enhanced-device-symbolic.svg | 1 + .../scalable/actions/bbm-eq-symbolic.svg | 1 + .../actions/bbm-extension-symbolic.svg | 1 + .../actions/bbm-gatt-bas-symbolic.svg | 1 + .../scalable/actions/bbm-github-symbolic.svg | 1 + .../actions/bbm-headphone1-symbolic.svg | 1 + .../actions/bbm-help-about-symbolic.svg | 1 + .../scalable/actions/bbm-hpf-symbolic.svg | 1 + .../scalable/actions/bbm-hsf-symbolic.svg | 1 + .../scalable/actions/bbm-hz-symbolic.svg | 1 + .../actions/bbm-input-gaming-symbolic.svg | 1 + .../actions/bbm-input-gaming2-symbolic.svg | 1 + .../actions/bbm-input-gaming3-symbolic.svg | 1 + .../actions/bbm-input-keyboard-symbolic.svg | 1 + .../actions/bbm-input-keyboard2-symbolic.svg | 1 + .../actions/bbm-input-microphone-symbolic.svg | 1 + .../actions/bbm-input-mouse-symbolic.svg | 1 + .../bbm-input-split-keyboard-l-symbolic.svg | 1 + .../bbm-input-split-keyboard-r-symbolic.svg | 1 + .../bbm-input-split-keyboard-symbolic.svg | 1 + .../actions/bbm-input-tablet-symbolic.svg | 1 + .../scalable/actions/bbm-legal-symbolic.svg | 1 + .../scalable/actions/bbm-logo-symbolic.svg | 4 + .../hicolor/scalable/actions/bbm-logo.svg | 1 + .../scalable/actions/bbm-logs-symbolic.svg | 1 + .../scalable/actions/bbm-lpf-symbolic.svg | 1 + .../scalable/actions/bbm-lsf-symbolic.svg | 1 + .../actions/bbm-mode-aware-symbolic.svg | 1 + .../actions/bbm-mode-commute-symbolic.svg | 1 + .../actions/bbm-mode-focus-symbolic.svg | 1 + .../actions/bbm-mode-home-symbolic.svg | 1 + .../actions/bbm-mode-immersion-symbolic.svg | 1 + .../actions/bbm-mode-music-symbolic.svg | 1 + .../actions/bbm-mode-outdoor-symbolic.svg | 1 + .../actions/bbm-mode-quiet-symbolic.svg | 1 + .../actions/bbm-mode-relax-symbolic.svg | 1 + .../actions/bbm-mode-run-symbolic.svg | 1 + .../actions/bbm-mode-walk-symbolic.svg | 1 + .../actions/bbm-mode-work-symbolic.svg | 1 + .../actions/bbm-mode-workout-symbolic.svg | 1 + .../scalable/actions/bbm-modem-symbolic.svg | 1 + .../bbm-multimedia-player-symbolic.svg | 1 + .../actions/bbm-network-wireless-symbolic.svg | 1 + .../actions/bbm-open-menu-symbolic.svg | 2 + .../scalable/actions/bbm-panel-symbolic.svg | 1 + .../scalable/actions/bbm-pda-symbolic.svg | 1 + .../scalable/actions/bbm-peq-symbolic.svg | 1 + .../bbm-phone-apple-iphone-symbolic.svg | 1 + .../bbm-phone-google-nexus-one-symbolic.svg | 1 + .../bbm-phone-samsung-galaxy-s-symbolic.svg | 1 + .../scalable/actions/bbm-phone-symbolic.svg | 1 + .../scalable/actions/bbm-pin-symbolic.svg | 1 + .../scalable/actions/bbm-pinned-symbolic.svg | 1 + .../scalable/actions/bbm-play-symbolic.svg | 1 + .../scalable/actions/bbm-preamp-symbolic.svg | 1 + .../scalable/actions/bbm-printer-symbolic.svg | 1 + .../scalable/actions/bbm-q-symbolic.svg | 1 + .../actions/bbm-quick-settings-symbolic.svg | 1 + .../scalable/actions/bbm-radio-symbolic.svg | 1 + .../scalable/actions/bbm-readme-symbolic.svg | 1 + .../scalable/actions/bbm-scanner-symbolic.svg | 1 + .../actions/bbm-settings-symbolic.svg | 1 + .../actions/bbm-spatial-motion-symbolic.svg | 1 + .../actions/bbm-spatial-off-symbolic.svg | 1 + .../actions/bbm-spatial-still-symbolic.svg | 1 + .../actions/bbm-speakers-symbolic.svg | 1 + .../actions/bbm-star-large-symbolic.svg | 1 + .../bbm-star-outline-rounded-symbolic.svg | 1 + .../scalable/actions/bbm-stop-symbolic.svg | 1 + .../actions/bbm-touchpad-symbolic.svg | 1 + .../actions/bbm-translation-symbolic.svg | 1 + .../actions/bbm-transperancy-symbolic.svg | 4 + .../actions/bbm-upower-devices-symbolic.svg | 1 + .../actions/bbm-video-display-symbolic.svg | 1 + .../actions/bbm-wearable-symbolic.svg | 1 + .../actions/bbm-wearable2-symbolic.svg | 1 + bbm/lib/bluetoothToggle.js | 740 ++++++++ bbm/lib/bluezDeviceProxy.js | 22 + bbm/lib/compat.js | 46 + bbm/lib/dataHandler.js | 198 ++ bbm/lib/devices/airpods/airpodsConfig.js | 231 +++ bbm/lib/devices/airpods/airpodsDevice.js | 784 ++++++++ bbm/lib/devices/airpods/airpodsSocket.js | 434 +++++ .../airpods/deviceConfigs/AirPods1stGen.js | 14 + .../airpods/deviceConfigs/AirPods2ndGen.js | 14 + .../airpods/deviceConfigs/AirPods3rdGen.js | 15 + .../airpods/deviceConfigs/AirPods4thGen.js | 15 + .../airpods/deviceConfigs/AirPods4thGenANC.js | 16 + .../airpods/deviceConfigs/AirPodsMax.js | 16 + .../airpods/deviceConfigs/AirPodsMax2.js | 16 + .../airpods/deviceConfigs/AirPodsMaxUsbC.js | 16 + .../airpods/deviceConfigs/AirPodsPro.js | 16 + .../airpods/deviceConfigs/AirPodsPro2ndGen.js | 17 + .../deviceConfigs/AirPodsPro2ndGenUsbC.js | 18 + .../airpods/deviceConfigs/AirPodsPro3rdGen.js | 18 + .../airpods/deviceConfigs/BeatsFitPro.js | 14 + .../airpods/deviceConfigs/BeatsFlex.js | 13 + .../airpods/deviceConfigs/BeatsSolo3.js | 13 + .../airpods/deviceConfigs/BeatsSolo4.js | 13 + .../airpods/deviceConfigs/BeatsSoloBuds.js | 13 + .../airpods/deviceConfigs/BeatsSoloPro.js | 13 + .../airpods/deviceConfigs/BeatsStudio3.js | 13 + .../airpods/deviceConfigs/BeatsStudioBuds.js | 13 + .../deviceConfigs/BeatsStudioBudsPlus.js | 14 + .../airpods/deviceConfigs/BeatsStudioPro.js | 13 + .../devices/airpods/deviceConfigs/BeatsX.js | 13 + .../airpods/deviceConfigs/Powerbeats3.js | 13 + .../airpods/deviceConfigs/Powerbeats4.js | 13 + .../airpods/deviceConfigs/PowerbeatsFit.js | 13 + .../airpods/deviceConfigs/PowerbeatsPro.js | 13 + .../airpods/deviceConfigs/PowerbeatsPro2.js | 14 + bbm/lib/devices/boseBuds/boseBudsConfig.js | 129 ++ bbm/lib/devices/boseBuds/boseBudsDevice.js | 1592 +++++++++++++++++ bbm/lib/devices/boseBuds/boseBudsSocket.js | 1272 +++++++++++++ .../devices/boseBuds/deviceConfigs/QC35.js | 25 + .../devices/boseBuds/deviceConfigs/QC35SE.js | 49 + .../boseBuds/deviceConfigs/QCEarbuds2.js | 131 ++ .../boseBuds/deviceConfigs/QCUltra2Earbuds.js | 149 ++ .../deviceConfigs/QCUltra2Headphones.js | 139 ++ .../boseBuds/deviceConfigs/QCUltraEarbuds.js | 149 ++ .../deviceConfigs/QCUltraHeadphones.js | 128 ++ bbm/lib/devices/commonEmuns.js | 14 + bbm/lib/devices/deviceUtils.js | 135 ++ .../galaxyBuds/deviceConfigs/GalaxyBuds.js | 35 + .../galaxyBuds/deviceConfigs/GalaxyBuds2.js | 52 + .../deviceConfigs/GalaxyBuds2Pro.js | 52 + .../galaxyBuds/deviceConfigs/GalaxyBuds3.js | 43 + .../galaxyBuds/deviceConfigs/GalaxyBuds3Fe.js | 57 + .../deviceConfigs/GalaxyBuds3Pro.js | 59 + .../galaxyBuds/deviceConfigs/GalaxyBuds4.js | 47 + .../deviceConfigs/GalaxyBuds4Pro.js | 59 + .../deviceConfigs/GalaxyBudsCore.js | 46 + .../galaxyBuds/deviceConfigs/GalaxyBudsFe.js | 46 + .../deviceConfigs/GalaxyBudsLive.js | 39 + .../deviceConfigs/GalaxyBudsPlus.js | 38 + .../galaxyBuds/deviceConfigs/GalaxyBudsPro.js | 49 + .../devices/galaxyBuds/galaxyBudsConfig.js | 157 ++ .../devices/galaxyBuds/galaxyBudsDetector.js | 119 ++ .../devices/galaxyBuds/galaxyBudsDevice.js | 1379 ++++++++++++++ .../devices/galaxyBuds/galaxyBudsSocket.js | 907 ++++++++++ bbm/lib/devices/gattBas/gattBasConfig.js | 44 + bbm/lib/devices/gattBas/gattBasDbus.js | 246 +++ bbm/lib/devices/gattBas/gattBasDevices.js | 189 ++ bbm/lib/devices/gfps/gfpsConfig.js | 32 + bbm/lib/devices/gfps/gfpsDevice.js | 302 ++++ bbm/lib/devices/gfps/gfpsSocket.js | 189 ++ .../devices/googleBuds/googleBudsConfig.js | 115 ++ .../devices/googleBuds/googleBudsDevice.js | 435 +++++ .../devices/googleBuds/googleBudsProtocol.js | 519 ++++++ .../devices/googleBuds/googleBudsSocket.js | 525 ++++++ bbm/lib/devices/logger.js | 84 + bbm/lib/devices/mediaController.js | 440 +++++ .../nothingBuds/deviceConfigs/BudsPro.js | 109 ++ .../nothingBuds/deviceConfigs/CMFBuds.js | 111 ++ .../nothingBuds/deviceConfigs/CMFBuds2.js | 125 ++ .../nothingBuds/deviceConfigs/CMFBuds2A.js | 116 ++ .../nothingBuds/deviceConfigs/CMFBuds2Plus.js | 119 ++ .../nothingBuds/deviceConfigs/CMFBudsPro2.js | 193 ++ .../nothingBuds/deviceConfigs/CMFBudspro2.js | 193 ++ .../deviceConfigs/CMFHeadphonePro.js | 117 ++ .../devices/nothingBuds/deviceConfigs/Ear2.js | 118 ++ .../nothingBuds/deviceConfigs/EarStick.js | 103 ++ .../nothingBuds/deviceConfigs/NeckbandPro.js | 90 + .../nothingBuds/deviceConfigs/NothingEar.js | 117 ++ .../nothingBuds/deviceConfigs/NothingEar1.js | 83 + .../nothingBuds/deviceConfigs/NothingEar3.js | 122 ++ .../nothingBuds/deviceConfigs/NothingEarA.js | 114 ++ .../deviceConfigs/NothingEarOpen.js | 90 + .../nothingBuds/deviceConfigs/NothingEara.js | 114 ++ .../deviceConfigs/NothingHeadphone1.js | 106 ++ .../deviceConfigs/NothingHeadphoneA.js | 105 ++ .../deviceConfigs/NothingHeadphonea.js | 105 ++ .../devices/nothingBuds/nothingBudsConfig.js | 172 ++ .../devices/nothingBuds/nothingBudsDevice.js | 856 +++++++++ .../devices/nothingBuds/nothingBudsSocket.js | 879 +++++++++ bbm/lib/devices/notifier.js | 91 + bbm/lib/devices/profileManager.js | 374 ++++ .../redmiBuds/deviceConfigs/FlipBudsPro.js | 79 + .../deviceConfigs/RedmiAirDots3Pro.js | 103 ++ .../redmiBuds/deviceConfigs/RedmiBuds3.js | 57 + .../redmiBuds/deviceConfigs/RedmiBuds3Pro.js | 88 + .../redmiBuds/deviceConfigs/RedmiBuds4.js | 78 + .../deviceConfigs/RedmiBuds4Active.js | 17 + .../redmiBuds/deviceConfigs/RedmiBuds4Pro.js | 90 + .../redmiBuds/deviceConfigs/RedmiBuds5.js | 99 + .../redmiBuds/deviceConfigs/RedmiBuds5Pro.js | 108 ++ .../redmiBuds/deviceConfigs/RedmiBuds6.js | 100 ++ .../deviceConfigs/RedmiBuds6Active.js | 85 + .../redmiBuds/deviceConfigs/RedmiBuds6Lite.js | 92 + .../redmiBuds/deviceConfigs/RedmiBuds6Play.js | 93 + .../redmiBuds/deviceConfigs/RedmiBuds6Pro.js | 116 ++ .../redmiBuds/deviceConfigs/RedmiBuds6s.js | 96 + .../redmiBuds/deviceConfigs/RedmiBuds7s.js | 98 + .../redmiBuds/deviceConfigs/RedmiBuds8.js | 100 ++ .../deviceConfigs/RedmiBuds8Active.js | 86 + .../redmiBuds/deviceConfigs/RedmiBuds8Lite.js | 95 + .../redmiBuds/deviceConfigs/RedmiBuds8Pro.js | 111 ++ .../redmiBuds/deviceConfigs/RedmiBudsSE.js | 72 + .../redmiBuds/deviceConfigs/XiaomiAirSE.js | 84 + .../redmiBuds/deviceConfigs/XiaomiBuds3.js | 57 + .../redmiBuds/deviceConfigs/XiaomiBuds3Pro.js | 88 + .../deviceConfigs/XiaomiBuds3TPro.js | 83 + .../redmiBuds/deviceConfigs/XiaomiBuds4.js | 72 + .../redmiBuds/deviceConfigs/XiaomiBuds4Pro.js | 95 + .../redmiBuds/deviceConfigs/XiaomiBuds5.js | 100 ++ .../redmiBuds/deviceConfigs/XiaomiBuds5Pro.js | 118 ++ .../redmiBuds/deviceConfigs/XiaomiBuds6.js | 110 ++ .../redmiBuds/deviceConfigs/XiaomiOpenWear.js | 83 + .../deviceConfigs/XiaomiOpenWearPro.js | 85 + .../redmiBuds/redmiBudsAuthenticator.js | 183 ++ bbm/lib/devices/redmiBuds/redmiBudsConfig.js | 117 ++ bbm/lib/devices/redmiBuds/redmiBudsDevice.js | 1187 ++++++++++++ bbm/lib/devices/redmiBuds/redmiBudsSocket.js | 986 ++++++++++ .../deviceConfigs/AccentumPlusWireless.js | 72 + .../deviceConfigs/AccentumTrueWireless.js | 66 + .../deviceConfigs/AccentumWireless.js | 61 + .../devices/senhBuds/deviceConfigs/HDB630.js | 97 + .../deviceConfigs/MomentumTrueWireless4.js | 75 + .../deviceConfigs/MomentumWireless4.js | 73 + .../deviceConfigs/MomentumWireless5.js | 76 + bbm/lib/devices/senhBuds/senhBudsConfig.js | 233 +++ bbm/lib/devices/senhBuds/senhBudsDevice.js | 1342 ++++++++++++++ bbm/lib/devices/senhBuds/senhBudsSocket.js | 1383 ++++++++++++++ bbm/lib/devices/socketByProfile.js | 169 ++ .../devices/sony/deviceConfigs/LinkBuds.js | 28 + .../devices/sony/deviceConfigs/LinkBudsS.js | 30 + .../devices/sony/deviceConfigs/Sony-ULT.js | 15 + .../devices/sony/deviceConfigs/WF-1000XM3.js | 22 + .../devices/sony/deviceConfigs/WF-1000XM4.js | 25 + .../devices/sony/deviceConfigs/WF-1000XM5.js | 27 + .../devices/sony/deviceConfigs/WF-1000XM6.js | 32 + bbm/lib/devices/sony/deviceConfigs/WF-C500.js | 16 + bbm/lib/devices/sony/deviceConfigs/WF-C510.js | 22 + .../devices/sony/deviceConfigs/WF-C700N.js | 26 + .../devices/sony/deviceConfigs/WF-C710N.js | 18 + .../devices/sony/deviceConfigs/WF-SP800N.js | 20 + .../devices/sony/deviceConfigs/WH-1000XM2.js | 19 + .../devices/sony/deviceConfigs/WH-1000XM3.js | 22 + .../devices/sony/deviceConfigs/WH-1000XM4.js | 28 + .../devices/sony/deviceConfigs/WH-1000XM5.js | 28 + .../devices/sony/deviceConfigs/WH-1000XM6.js | 31 + .../devices/sony/deviceConfigs/WH-CH720N.js | 24 + .../devices/sony/deviceConfigs/WH-XB900N.js | 21 + .../devices/sony/deviceConfigs/WH-XB910N.js | 23 + bbm/lib/devices/sony/deviceConfigs/WI-C100.js | 14 + .../devices/sony/deviceConfigs/WI-SP600N.js | 16 + bbm/lib/devices/sony/sonyConfig.js | 554 ++++++ bbm/lib/devices/sony/sonyDevice.js | 1411 +++++++++++++++ bbm/lib/devices/sony/sonySocketBase.js | 369 ++++ bbm/lib/devices/sony/sonySocketV1.js | 1047 +++++++++++ bbm/lib/devices/sony/sonySocketV2.js | 1516 ++++++++++++++++ bbm/lib/enhancedDeviceSupportManager.js | 363 ++++ bbm/lib/panelButtonMultiDevice.js | 54 + bbm/lib/panelButtonSingleDevice.js | 155 ++ bbm/lib/upower/upowerDevice.js | 296 +++ bbm/lib/upower/upowerHelper.js | 92 + bbm/lib/upower/widgetManagerUPower.js | 84 + bbm/lib/widgetManagerBluez.js | 256 +++ bbm/lib/widgetManagerEnhanced.js | 243 +++ bbm/lib/widgets/batterySetWidget.js | 135 ++ bbm/lib/widgets/bluetoothGnomePopupMenu.js | 89 + bbm/lib/widgets/bluetoothIndicator.js | 151 ++ bbm/lib/widgets/bluetoothPopupMenu.js | 229 +++ bbm/lib/widgets/bluetoothPopupSubMenu.js | 395 ++++ bbm/lib/widgets/checkButtonBin.js | 194 ++ bbm/lib/widgets/circleBatteryIconWidget.js | 199 +++ .../widgets/circularBatteryVectorImages.js | 14 + bbm/lib/widgets/colorHelpers.js | 236 +++ bbm/lib/widgets/iconGroups.js | 31 + bbm/lib/widgets/indicatorIconWidget.js | 369 ++++ bbm/lib/widgets/indicatorVectorImages.js | 12 + bbm/lib/widgets/labelIndicators.js | 97 + bbm/lib/widgets/multimodeIndicator.js | 183 ++ bbm/lib/widgets/onHoverMenu.js | 109 ++ bbm/lib/widgets/optionsBox.js | 136 ++ bbm/lib/widgets/panelPopupMenu.js | 223 +++ bbm/lib/widgets/popupMenuWidget.js | 291 +++ bbm/lib/widgets/radioButtonBin.js | 207 +++ bbm/lib/widgets/sliderBin.js | 86 + bbm/lib/widgets/toggleButtonsSet.js | 313 ++++ bbm/lib/widgets/tooltip.js | 186 ++ flake.nix | 1 + modules/bar/popouts/Bluetooth.qml | 3 + nix/bbm.nix | 49 + nix/hm-module.nix | 37 +- services/BbmService.qml | 10 +- 405 files changed, 38953 insertions(+), 4 deletions(-) create mode 100644 bbm/daemon/bbm-daemon.service create mode 100644 bbm/daemon/bluezEnumerator.js create mode 100644 bbm/daemon/dbusService.js create mode 100644 bbm/daemon/deviceRecord.js create mode 100644 bbm/daemon/main.js create mode 100644 bbm/daemon/notifier.js create mode 100644 bbm/daemon/orchestrator.js create mode 100644 bbm/daemon/schemas/org.bbm.gschema.xml create mode 100644 bbm/daemon/settings.js create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-adaptive-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-add-toggle-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-added-toggle-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-anc-off-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-anc-on-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-anc-wind-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-card.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-headphones.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-headset.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-audio-speakers3.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-bluetooth.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-camera-photo.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-camera-video.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-computer.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-bean.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-neckband.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-stem3.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds-wingtip2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-earbuds.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-headphone1.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-gaming3.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-keyboard2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-microphone.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-mouse.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-split-keyboard.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-input-tablet.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-modem.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-multimedia-player.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-network-wireless.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-pda.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-phone-apple-iphone.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-phone-google-nexus-one.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-phone-samsung-galaxy-s.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-phone.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-printer.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-scanner.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-touchpad.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-upower-devices.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-video-display.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-wearable.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-art-wearable2.png create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-card-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-headphones-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-headset-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-speakers-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-speakers2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-audio-speakers3-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-battery-widget-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bell-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connected-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-1-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-3-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-connecting-4-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-1-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-3-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-disconnecting-4-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-gui-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bluetooth-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bug-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-bypass-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-ca-off-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-ca-on-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-camera-photo-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-camera-video-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-narrow-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-normal-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-oval-short-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-oval-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-round-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-case-square-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-check-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-color-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-computer-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-dark-mode-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-db-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-device-mgmt-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-bean-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-neckband-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-stem3-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-left-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-right-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-earbuds-wingtip2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-edit-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-enhanced-device-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-eq-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-extension-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-gatt-bas-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-github-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-headphone1-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-help-about-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-hpf-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-hsf-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-hz-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-gaming-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-gaming2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-gaming3-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-keyboard-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-keyboard2-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-microphone-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-mouse-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-l-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-r-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-split-keyboard-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-input-tablet-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-legal-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-logo-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-logo.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-logs-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-lpf-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-lsf-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-aware-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-commute-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-focus-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-home-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-immersion-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-music-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-outdoor-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-quiet-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-relax-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-run-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-walk-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-work-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-mode-workout-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-modem-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-multimedia-player-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-network-wireless-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-open-menu-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-panel-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-pda-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-peq-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-phone-apple-iphone-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-phone-google-nexus-one-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-phone-samsung-galaxy-s-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-phone-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-pin-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-pinned-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-play-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-preamp-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-printer-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-q-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-quick-settings-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-radio-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-readme-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-scanner-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-settings-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-spatial-motion-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-spatial-off-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-spatial-still-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-speakers-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-star-large-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-star-outline-rounded-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-stop-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-touchpad-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-translation-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-transperancy-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-upower-devices-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-video-display-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-wearable-symbolic.svg create mode 100644 bbm/icons/hicolor/scalable/actions/bbm-wearable2-symbolic.svg create mode 100644 bbm/lib/bluetoothToggle.js create mode 100644 bbm/lib/bluezDeviceProxy.js create mode 100644 bbm/lib/compat.js create mode 100644 bbm/lib/dataHandler.js create mode 100644 bbm/lib/devices/airpods/airpodsConfig.js create mode 100644 bbm/lib/devices/airpods/airpodsDevice.js create mode 100644 bbm/lib/devices/airpods/airpodsSocket.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPods1stGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPods2ndGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPods3rdGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPods4thGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPods4thGenANC.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsMax.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsMax2.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsMaxUsbC.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsPro.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsPro2ndGenUsbC.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/AirPodsPro3rdGen.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsFitPro.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsFlex.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsSolo3.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsSolo4.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsSoloBuds.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsSoloPro.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsStudio3.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBuds.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsStudioBudsPlus.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsStudioPro.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/BeatsX.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/Powerbeats3.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/Powerbeats4.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/PowerbeatsFit.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro.js create mode 100644 bbm/lib/devices/airpods/deviceConfigs/PowerbeatsPro2.js create mode 100644 bbm/lib/devices/boseBuds/boseBudsConfig.js create mode 100644 bbm/lib/devices/boseBuds/boseBudsDevice.js create mode 100644 bbm/lib/devices/boseBuds/boseBudsSocket.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QC35.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QC35SE.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QCEarbuds2.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Earbuds.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QCUltra2Headphones.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QCUltraEarbuds.js create mode 100644 bbm/lib/devices/boseBuds/deviceConfigs/QCUltraHeadphones.js create mode 100644 bbm/lib/devices/commonEmuns.js create mode 100644 bbm/lib/devices/deviceUtils.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds2Pro.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Fe.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds3Pro.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBuds4Pro.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsCore.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsFe.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsLive.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPlus.js create mode 100644 bbm/lib/devices/galaxyBuds/deviceConfigs/GalaxyBudsPro.js create mode 100644 bbm/lib/devices/galaxyBuds/galaxyBudsConfig.js create mode 100644 bbm/lib/devices/galaxyBuds/galaxyBudsDetector.js create mode 100644 bbm/lib/devices/galaxyBuds/galaxyBudsDevice.js create mode 100644 bbm/lib/devices/galaxyBuds/galaxyBudsSocket.js create mode 100644 bbm/lib/devices/gattBas/gattBasConfig.js create mode 100644 bbm/lib/devices/gattBas/gattBasDbus.js create mode 100644 bbm/lib/devices/gattBas/gattBasDevices.js create mode 100644 bbm/lib/devices/gfps/gfpsConfig.js create mode 100644 bbm/lib/devices/gfps/gfpsDevice.js create mode 100644 bbm/lib/devices/gfps/gfpsSocket.js create mode 100644 bbm/lib/devices/googleBuds/googleBudsConfig.js create mode 100644 bbm/lib/devices/googleBuds/googleBudsDevice.js create mode 100644 bbm/lib/devices/googleBuds/googleBudsProtocol.js create mode 100644 bbm/lib/devices/googleBuds/googleBudsSocket.js create mode 100644 bbm/lib/devices/logger.js create mode 100644 bbm/lib/devices/mediaController.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/BudsPro.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2A.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBuds2Plus.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudsPro2.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFBudspro2.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/CMFHeadphonePro.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/Ear2.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/EarStick.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NeckbandPro.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar1.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEar3.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarA.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEarOpen.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingEara.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphone1.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphoneA.js create mode 100644 bbm/lib/devices/nothingBuds/deviceConfigs/NothingHeadphonea.js create mode 100644 bbm/lib/devices/nothingBuds/nothingBudsConfig.js create mode 100644 bbm/lib/devices/nothingBuds/nothingBudsDevice.js create mode 100644 bbm/lib/devices/nothingBuds/nothingBudsSocket.js create mode 100644 bbm/lib/devices/notifier.js create mode 100644 bbm/lib/devices/profileManager.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/FlipBudsPro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiAirDots3Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds3Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Active.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds4Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds5Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Active.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Lite.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Play.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds6s.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds7s.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Active.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Lite.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBuds8Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/RedmiBudsSE.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiAirSE.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds3TPro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds4Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds5Pro.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiBuds6.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWear.js create mode 100644 bbm/lib/devices/redmiBuds/deviceConfigs/XiaomiOpenWearPro.js create mode 100644 bbm/lib/devices/redmiBuds/redmiBudsAuthenticator.js create mode 100644 bbm/lib/devices/redmiBuds/redmiBudsConfig.js create mode 100644 bbm/lib/devices/redmiBuds/redmiBudsDevice.js create mode 100644 bbm/lib/devices/redmiBuds/redmiBudsSocket.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/AccentumPlusWireless.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/AccentumTrueWireless.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/AccentumWireless.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/HDB630.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/MomentumTrueWireless4.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless4.js create mode 100644 bbm/lib/devices/senhBuds/deviceConfigs/MomentumWireless5.js create mode 100644 bbm/lib/devices/senhBuds/senhBudsConfig.js create mode 100644 bbm/lib/devices/senhBuds/senhBudsDevice.js create mode 100644 bbm/lib/devices/senhBuds/senhBudsSocket.js create mode 100644 bbm/lib/devices/socketByProfile.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/LinkBuds.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/LinkBudsS.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/Sony-ULT.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-1000XM3.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-1000XM4.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-1000XM5.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-1000XM6.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-C500.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-C510.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-C700N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-C710N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WF-SP800N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-1000XM2.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-1000XM3.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-1000XM4.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-1000XM5.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-1000XM6.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-CH720N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-XB900N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WH-XB910N.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WI-C100.js create mode 100644 bbm/lib/devices/sony/deviceConfigs/WI-SP600N.js create mode 100644 bbm/lib/devices/sony/sonyConfig.js create mode 100644 bbm/lib/devices/sony/sonyDevice.js create mode 100644 bbm/lib/devices/sony/sonySocketBase.js create mode 100644 bbm/lib/devices/sony/sonySocketV1.js create mode 100644 bbm/lib/devices/sony/sonySocketV2.js create mode 100644 bbm/lib/enhancedDeviceSupportManager.js create mode 100644 bbm/lib/panelButtonMultiDevice.js create mode 100644 bbm/lib/panelButtonSingleDevice.js create mode 100644 bbm/lib/upower/upowerDevice.js create mode 100644 bbm/lib/upower/upowerHelper.js create mode 100644 bbm/lib/upower/widgetManagerUPower.js create mode 100644 bbm/lib/widgetManagerBluez.js create mode 100644 bbm/lib/widgetManagerEnhanced.js create mode 100644 bbm/lib/widgets/batterySetWidget.js create mode 100644 bbm/lib/widgets/bluetoothGnomePopupMenu.js create mode 100644 bbm/lib/widgets/bluetoothIndicator.js create mode 100644 bbm/lib/widgets/bluetoothPopupMenu.js create mode 100644 bbm/lib/widgets/bluetoothPopupSubMenu.js create mode 100644 bbm/lib/widgets/checkButtonBin.js create mode 100644 bbm/lib/widgets/circleBatteryIconWidget.js create mode 100644 bbm/lib/widgets/circularBatteryVectorImages.js create mode 100644 bbm/lib/widgets/colorHelpers.js create mode 100644 bbm/lib/widgets/iconGroups.js create mode 100644 bbm/lib/widgets/indicatorIconWidget.js create mode 100644 bbm/lib/widgets/indicatorVectorImages.js create mode 100644 bbm/lib/widgets/labelIndicators.js create mode 100644 bbm/lib/widgets/multimodeIndicator.js create mode 100644 bbm/lib/widgets/onHoverMenu.js create mode 100644 bbm/lib/widgets/optionsBox.js create mode 100644 bbm/lib/widgets/panelPopupMenu.js create mode 100644 bbm/lib/widgets/popupMenuWidget.js create mode 100644 bbm/lib/widgets/radioButtonBin.js create mode 100644 bbm/lib/widgets/sliderBin.js create mode 100644 bbm/lib/widgets/toggleButtonsSet.js create mode 100644 bbm/lib/widgets/tooltip.js create mode 100644 nix/bbm.nix diff --git a/CMakeLists.txt b/CMakeLists.txt index 48ad47967a..88ffd35ad5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,7 +62,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..200cdaba20 --- /dev/null +++ b/bbm/daemon/dbusService.js @@ -0,0 +1,218 @@ +'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 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..0ac3a7ff7b --- /dev/null +++ b/bbm/daemon/main.js @@ -0,0 +1,42 @@ +#!/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 ?? ''); + 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..25ee987f0a --- /dev/null +++ b/bbm/daemon/orchestrator.js @@ -0,0 +1,211 @@ +'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, configChangedId, propsChangedId} + 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() { + 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 + ); + // ESMD stores the dataHandler in its own map and calls toggle.sync(); + // we read it back from the return value here to announce to D-Bus clients. + if (result?.dataHandler && !this._pathToAddress.has(path)) + 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 by EnhancedDeviceSupportManager when a device's protocol handshake + * completes and a DataHandler is ready. + * This is the moment we create the DeviceRecord and announce DeviceAdded. + */ + updateDeviceMapCb(path, dataHandler) { + if (this._pathToAddress.has(path)) + return; // Already announced; further updates come via configuration-changed signal + + 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; + + // Create the canonical DeviceRecord + const record = createDeviceRecord(address, alias, icon, connected, paired); + updateRecordFromDataHandler(record, dataHandler); + + // Subscribe to DataHandler signals — update record and notify D-Bus clients + 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); + + // Announce to D-Bus clients + this._dbusService.addDevice(address, record, dataHandler); + + // Trigger another sync so WidgetManagers (if any) are notified + this.sync(); + } + + _onDeviceAdded(path) { + // BlueZ enumeration found a new device — run sync to detect type + 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) { + 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 0000000000000000000000000000000000000000..6e1c289e2d9e1fb71603fc3372cd24802c2157ed GIT binary patch literal 11946 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 B!~_5U literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..719a7461424b546645b232e9109149a8341a88fc GIT binary patch literal 27836 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 S6YLpA*{z$}pf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3284e9cb9f327a1f453838fbde0f30fc9550a9a2 GIT binary patch literal 30817 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

rSWaM~h5P4uO56C`t znWb*w1M41mQL68PhZK!gUDsBP=5F3{d~cruRO7u3=di2qbZQuwmW> zEEd$2GlaO{Ur0MuDjY30=3m_wHF*Di=RxV?wyw7C&dafXR#)c$rvl}(5y)TrPW0?u zO$q^QsLe6Fy3adiSSNPvVJLbk@KI6Qx1YUr`*vgSOh+3&?v=edc|${HXRF5uI1X7J zzWW;6f)01zehws2>*b@MN(us~fq-^)$6TGyp%4K)Fvo#H*?_(PG;_W|l_J0%puz6G zW`~@-zvf2~jDh@~ymWANhN{C=uT0FCFUd6O|+qJ*cO^>c8umzKa=zIihAWr3O- zKj7NXr`ZbtMwiE`Vi%eM=anPyYP*G8T=DHH?sp~$;Q_hZD!Z5y^#vfzy6H#Fa^_4Ke`(rNEQ+6f|B{$d!Rehp*HSxzzZ zKxL+{H+-^$(Ru$I0&(Mq^r#ib2tWo{Aid{Z?8v>jxw-ym&3k?V4QWF?MYrY3K1c6O^H3m$e;F+G+qE-~fI{%?( zMBIMh{(V)@D?rmrJ&n^dd=$ncsR(K)+4~idKekW*06rsZdr4dXXSa+m~k95S)*lfwO0-~ZT=+8O)!wF>yG`AN*XhGvAlM-Gs*A3c3L~@}A;~*%L zoDTvIj$V#EdNUM%)@gOPz`sqR_@-D6SR z$6&~WSzHAZzmg=6CUXi9?sqx7lMMHI>t3{(U}@4&wl^BxTAe{-YB+zYhv;`SXaf_Srj^j7CfNrh1d;%m!K1M z;z4AsP8EBj%qR7Jcw~sc@^yqLtcybiMb;S6!fK%h>=`YG&I0w|N>!AoWH8nB_BpV^m-GsdQCq(fj;#|cq zZV6-!X3|1@%+Ae0)q`9MTo}iX;T$AORWpMA4p>PK8N(a>@1fUV&NoQUtxjZX3A1e9 zW-JHl81(d@>?m^TI{OcS6a%SwSK`Yq^QMPIg^yPa5cX+$8RXdj(l1txqhSVtqy>;I z0HMB+;Svb+*?yC@;rG$rCZ%&C0$xixj`-E}<|UV{7w!PE{z3WD?p>uG!uYt_|2mx| z-^f2f&s%!A?<`OKPQO{nGUgZXRnci>Obwh>;hd=81%5Jw-~l=o@Jk~MBEiXKx8j5l z>ZbMF7=xwd014Ynw_P|;Au?W_qAg*FUqZ546EI91vh}2re0m_v_~3!V?p7i2{6MZPoS`!ZNYc?^0?=_JJ6p5rBiLsV z`syIiq=hs`iXh)YaSMPRp~jXO_^gFSP9K=4zK6*h5K!^a+->dcw*%`7K}!PVchBhu zJFh{sVcGzXEH_OEIq(yMB^D}&l1{+huN=A7?k#YWoDj@THU%DKqW73D4%ejs=lE@W zydYIz>*9G()?^T;up*66L9Q&#hmsPZi_UZiU3Eotvul0olqq!k{esU0b76jQ%GP^V z62!Dmf$T|`Zg1)=taZj>lp%I2bfvA}zHbX$er)nwn zeUAm)*Rel1QTqy4oL6tV@A5%SAUJl1-1}^5R_*iZK*PBAemX9io%)rcxc53M`a~9O zQ7i712ow&Cq?NnMBt!g{a&zAdx1A2%1AK<`fEuH4igpCbIJOp@T!;Qm5%S<*KuxqiwzQrZ-khs&Mtj}1wc55XK|wRT@%0iJ)AZ2x;)es4v%p}X zColk-A%werneAd@dU;%L<|cf@0EdWX;>MbKjP{k+&mS{S_iLSdcVq>D{AP7rcki49$YdZ+FzT=MWRURCp;h4;{PYq=ym6O3-P+X-Wkq39 zvKYlF!LEX2UCT#LOrdg%~bf9lz7v-Ew$|rzrP6JnC(VcA8vNC$V z?&)>f3(Yexny6@Lij9xm0Ia|Lac%;V2YfLqD?6LzV~;U%g5;PdYkk`AZJ=VCZ7UAd z#GrN3v2qArMDaVtseTJ8C;0io!?g03Q!QH106xA~V6Li8YH68${0I_(l+~ke*L0Bj z-8(SQ3KDN7S+CB8yE%x00EB#Og69N)l!Ps6=Nrs?X^TpfagTw@@Zbeitf;G?_xk=E zkKyM~;3Kwcu{9+Dn zmOvU4{S%(i0GL}in}sl85Rj5cza0dq|0ZOs1e8C6Xy^h^1a(L<;2ryhIvF~X;8?#^ z2gD-*^a&7jzx&MSl5es0)pb^%n#nYj;+#Q0>y+aZZ!dDUhsAn9uHFZGzgVvoq)ds+WoZ|?Q1^6(e z!CeKGjev0TZ~wCmv=U+=1Vi*+aTq8Oi}i+BDZJWCz{9*iOhWL?vNDwqj{L3iGzUL_ zEz9}vu^XB7)?tMmi`06%3hlHm$v%nIUJ8Qm5p;n7QluPj4s{+}i5e2K+&u>6Lure` z*Au_o0Q$nV0f)Qo_EoeQ>KYjVwIDS_{3Y&n!wi`R!JiHSSaE5iGH9mc{J^463!QyW zce7{=PqXt6a-ghHMWInwVRwO)7rxBN{b}Kg3inQB_pEZG35xska{=S#=f_E_2TWfW znaDYM?LpTwYL3>U>IEEc9p%H+M>M=<3lAUcX&I@|xid!oENZiTNs~xjAIcfg>{wJA zWj=O;D-(ALA@>5T{+iLG(i3aF^N<_-f>jE`*Broia+*D6Px~%n^z$|(Z9=F>@Q20q(yd_)9OhzvTLkiLPbJhUTUTrCL~+S)L!#zaWpow|W|fkDdr0dS~yl z`pI~1;1o7Mg&G2IEjfrH?@VxoFmic)_ zh@zerQ*Knpuw^WzK|OZ3we9U-S>C!LX?|@^bNORpX{XvcRV>ZmsFISNs|8<^xOjQR z%dczBsHD_a=0hWM?0J7=LB};o}#bX<&vh=wCA1~BT;tth9(#TlCeFn&dRD**VjKK8;8izA}~;yw7yU@S7WSA zAQJ2S)<$;SyElZl;DaxoqO#Jfdqv1JI)Ej=*1GS3q8lL2vIVS{D+2t_nwsl-gw+_Y zyaVCw`}WqG_|}Q+qk7sfOhYehvD0^{STC7HmmD>)2FEaNm%SbrSM|6PK`x!9ZtHw8 zF{+sun_ydwytqe1gC3 zYSZShvX)j`$@CS2)>ddR3Czj^I(eL)or`<>OzMuXR%`A+a$-UP zg19#EJt{jj#j6K;&KWvKa&)=@;ok4`^sD(beVfas<8era@^WDzH7&!hqNrnYIM7jr z+InsEpZw?F$u-NMu7n1_w~T4|#dOATKKHoHakFuA)lxj}IHVMhot-~_{<2J={zsO| zlVzc?@rRWB+r2BH@nU~Tif$}_S!rftW#D0*y~!$>%+vCrL!1e1ZIe=xjMvZ1{;nzT zX>C83pu>oC(Ghj|ONbg!MBwY zTR-&Q9k#GDIp*i_qRk$8@6vOJnm&K#u`QyWId+VtV0x29)}WQ);W>@>?yUunoB@oZ z`{p)=xZStyUL7|x+@|yORmwKL!xwl!#iT~tNN#SoFgLkMo)&)?mP6ji7Sz?o9t)g1 zXMWz7==SEXoHR7vJsYrtw8l*}IO$^LELa>lN*atyOUtMub`Q^VzoDOD{HHbKt?sLA z{FPr}6ncMlmJFh$JoCdkyj-6K2HHKgZ1E2au>4EGMndu~m?L>eYB1P1@h=CH@jkx6 zcmD(gAbp4sYi@EIdi@@ee3GsyDM3~9>RX9FV9FGSc>PB?(^HR2tFz+9F9Tt)SJaiz z&@8CrnORv98XK@JER9_3}|g9m&#{ZS$*3a4xPj1-DR9L+Y@GJFgiL} zE=dM&k|e%+YoSez{)rP9a2z?R0j&@5!1rUki7g0hYJr{Wtd-rgq7LHoM!_tR0$^9S znT`2yj5hoqu@11*OZG-P&MPSa?=P9)FcBc{_V%lk7xA;L23$(Y)!BK#uK)CXBALTW zNN7@4>OsMyMt9;S1u2Ao!5zJ*X{aux0WBEU1d~2I*eb2FPg*Va5gvxNQ!}prPPY9&D}m285@7{`_38` z7Z063zFGfI86rvlfD(@mVfaz|KaNqAmGxH>+=;ffym|<0OF_B0l$1KI0I&V`6ezTH43%-QfAK`y5ENLK|~sd1^o9 z)X*YhE?hx@@{qTF?A{3u2pr@0PSe1tua0vWpuPi$Ma^?>cbbgSRZvh6gj`80IvPx2 zC)droXNY#yyi(LWZGDP8dAq=ts*-GbKM(1m>E5OX&H7*fyvZ^Qy6pAxTN_b#^x;g; zc@Fx->?;o+c!D{&sZ!+?v+=eyZeAXa2lp?y7=T_^I)cS~d13ymr>o%S@bKYrKO=A% zckWy*eioZoH_O=)7FrvL?0m0EICdvz^o7bRLIPbyuFJDcYLODj_#^(m!~zx;YAeDj zYnBPHV9*3$TwtZy34lvq{&EOH2Af1CBnIsZ3nnkUvC(>ZIoSQPPYCgMCs26)OD--k zD;V|*)GI>LSmND95f&F$SGAB?+|c>+pOqA=(u#g^L{Or6Vo&a?56}Ga!lI1W zuYT3f4;##J>!g^VM&Rv-ou;5^Rm#f431r|SZ|~A;){K~&Z|z^k*<077=j7zD??#(G z<4H+L5uBc+_{`Yt=Mr`rUkUI|N;ADkciB&Zge+C1#iY};Ya88xu;11h}T3g1u?IAtYFuSh^Z z3E49w9RX!e#>X!&LY#&SfKzXC1w`RIj$$eW4ymkUAK(j5+fvWKTMoX<7@L?GnSA93 zzIv#WSsa}f^+T6qX%|za>o{j_PL3ea@rVBG+?*v9x>C9dorex2CS|9bfXL>?@g##? zi7lD2`k6U7KgYkku-Ui&(xppLD*F208jLxgVCC5FShGvrb#ZY)JfA48uK$;_E;S$t z=S~>2XUi$!BI9JGT#r*MFgZ<45jcVsCt3Hmya>y@6*qyLe#1`0_easX@`Na%ll*~g zLuZooIuuVX#|eh`q1-Bk;m=#Am|b2G5|K9x;&FsXr7ij4a~vod%Y+!g^!}gHZmJqe z5%O^|y5M3x>a43Rj=))N|9j;_-})dcvuYt3C*^YnZTwyZjXLXo4IWY|=a(wkBjhIz z5KKkt9=F^a4vEyk;K@8P-u&x%-mIwmG<5=*N0QpvH?j>CmY)KfmX4UCX-DsWvw{a4 zLw6A}c|W`5RO*5)fgtPb0QzpfC-smXT4r^6dp4xmsT5z35fT_$A8?T_r?=&IQ> zauX%&+0(O{s7h7?HRe2zW6e=xe6DYv`$5_PE3x-nv^{ZP+cj|ABycsb>3-pj7 zbUvKZVx(DL^Bq;@&fUR->9BMqka-gf7}B)Kx_vAt{QCq}P41tT-UPC6l?g+DGO|uD z&DU7Ee@ui+)1ytC5XxBJ5xfvcPYt+Baau=o(DeZl-tVj9xu@8)IXz#l zlX3k)>=C3vF4xh1BNRspQJAqvm=TmDkf#%g4Ex9fL7@@(HJXvy4uU-_nm_*};l^^~ zQFK<$nPCyjZ9ZbB2s%7Vw--;6DPhxnH16r&FzPRFL<_@j2#UZW2{RbF?JbP6EQ6rL zl9)6=x&S(qpMf`Cj+d)I?PTwbOry2b8N=XRESk=8kZ(M~a$9)RC(5E|y8bs5B<5<-R;d@NQ4@ZK+vD;H|V{l>0y!U#_ z;&+DSBDh%HDVu#u+Y9%I5IPHPCFPDU+VYM@QdfmgSKQEtS+EUzG2(>kOk!vJ+8V=- zbhhhS_5DY>+{IfKxxi$!+3|8S*1`)#?OFv5J6f@;o%b9Py#xI)sAbOpHDM7P{Z22<)x)+h6%Tmm)n(i)v z<)rOc(qy8UITv-w{@5&{_*u5kuCviM(1DJ*=n9N6ChtJq40xQN%W?BoN;`Cg7JUnd z7+U`_jv40Hz}Q1$k8~#Hi4*4V=sMj#n@8Rfb^g~xqSJfY+R)l3LpLH@BIs-b+@2y@ zeGXfgxv#pT*Qg?d$Rx44aXX${NknzhK2>%>_LWElnv^heJbdxf220jV!dcG_1sAwe P5Jk{CbF%QbL)8BON&4H$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6ab047991ca355aa56e7e389d37ffc4309eaebff GIT binary patch literal 11168 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 a+~O{dS^F6)x?31l073oY*;l7+Z~hNqRd4J7 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9d00ea38ae92520927109a2f02a903652ed3f860 GIT binary patch literal 8719 zcmch7cTiK^xAsmDP*C)J1yO01(4+}SRf-~F=u$+KDqT8AlNLlkK}8dKjlP1?iy#6D zhysS*J7Ni;_YQ&FeRRJ0X71cSe)s+34#S+}oL$!5Ypv&b);@WptF3l`={OSr-~jgO zWjz4U@FN=RWq_9z-|j7V*<*K6>mmR}(acm!diWjXsi$@cH9!&$hF?xtUDeY9;2s|U zxCa0r?Qs(Tc%21c$^roS1OV9GQYx-pfDagMYp7iYG~|!?@ihV7+2?lE*b@Ne!^l4h zB&8gMBLeK@iw3@fvm<`b497yG7hYG!DhNE3e={Kv#&Ka_*t~ z&*QOe4}ju=c6b-8Jg-CbkM%of zWnO#j&KPTFd^ko3FRW91j>JRw5JPUm?w0&m&%TtAPG;cS)Wh#$888Fqv3b?Z!0F2t zrj36y5Z^!$*3>HiF%i4iaK2-B@&d+`vkf?f^VXbl|0;dpeY*j2d7vnFR2?S0TU=owa;TTszkesQ4 zU&f~-TZ=Uu0yZ;s7y_PpfpnbyJ0UfWJcO;9NI((qQ%{n-ur7vRx3>oK3;)>TG0^=q zr!FDRTUeuO`n+sZlw>1l!Y$YDj#3VrPOk0iFT+~aeR?UPVA70s^Tki=zwamrwL;_0 zbes$`bgWJwm$JGX%}qkPxih7Qt$1<*-HfeQh51@mkqgh2Z?WSP2qvlIB}{6I0x-dXI32<+lm#9SEVR;2cem@x! zAyyZNYAtnP&L`di8wMkI&e+;ORJBMSy8R?ciFeZy@7y8?obb*B&cs(c;eWT^(55H) zrNVB-rgQ}V-#R&s0z4tTosY;+EVBbW7b8!DvWJcc!>@;TMe;tXO>}Z z6*(>Rvf>oznLC-uVuVdHn{2=BDyS{J0!macb=JeISYRXcmDB{daY}>}*?rT%jL>&1ME4MTN zG4V$DfRm)imXBz+aKwT<1?iL~nQf*TeJsl}9{Bw=f%Cnb5vZCY9|Sh@xC7%WTB6`} z<9=|a_Db6%$M#;LIi7!v9h z1aR5yv|scVxC?-IX8%n5S>T5?=rBJyplrAq;Eey~0mtgBl*Z~HcP5RKUnO$_V)oq}txhb`SiT3dd6pzy z3|n>FAk;=GOa1<6LU?5iADarJ_5(-?M2OM<<{|)D020Z+x%}HHVZVPnCDQNTtn&Yx z`XA0*lIwgKWH_`FFCLSyzPk3!e|}}EM@Zq`%GC%KuFk5!ZKulFca!ZeLZ3xCjZ}E? z?Bp81<#Vmx@s1O@6~}JodvbMkHBrhj?wKyY>B!vaezTuP@d6{KoAMfDu)EW}S^_q6 zJL{uuO=8t4yc%(2nd(igOwGhotm;>^@V4U0?kQfq#&VOd2%QDy)idvZJw<1^%~x%^ zr1?=2hl6Q5Cv1!t)X&^_2*

DRluyJN~d$0-}XqILB33Cg;F*l*jKl6Y8K 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..45788f47bae18a1f9dd8bb94f449d3bea2aad945 GIT binary patch literal 7373 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c1d398b351d1de3258719155ac8b8df0805aa13a GIT binary patch literal 27585 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e604addaa15a9e65c941fe491e5daa592ad3b95f GIT binary patch literal 20815 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b71ed9528a51528614d546cdeffba86319ba975b GIT binary patch literal 21327 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..560e201d61601ad83749ddae24fb1cf733e3ac67 GIT binary patch literal 10558 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 IF?;y`05Mk>^Z)<= literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0261a8531e3f0f43bea4804cc1795202d73d6e96 GIT binary patch literal 25607 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 N%Q|;P>Dr*<{{hK^n7RM} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..563c947760779c819f2c94528bcbdc34fea6f0d4 GIT binary patch literal 30757 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..33534aaee7da8416e8c2796c285802a46ba5f8aa GIT binary patch literal 25513 zcmeFZX&_c@A3eH7LJB3R3`J37NQKNv6on*YEJ-5sJZlg_lB7~*NkV0wNv4n#$vlP3 zgv^|^pZA<^=gax<|8}0Y_j$y9?|bj-`VH&1*180!t14}w+f7Fh#Fn#X6f_881HRlq z&`{$)vo1|b_z%^Mljlzo#D|d0D_1w-?-ceLO7aw~HHW?Nm%Z1{Xq+bqHy(oUxknJ> z>wW$ZgrhJ)j9eiIiDv|{)8=LV1u6W1+E_(NfmkR17gLlJhVRhYoVjdI5SzD?|EC~c zykx}{iL(kPHC@`qI&a+4zY!(4kn~pP#E|g-AIs$+W0J&02{`&6L4UpU*nqmz^6 z?(@#!it)?(vm2JL{xg;p5M+5K=`8dl~q80?zEO|(Lvu)bApU@$v&h5f$7(A3MJ^Xu9^5rH&vqCV#c8DfzTArdIRQK}P< zyu4o$3JOo?v?%UT942TeST{{EmU3JOA3s7o&sH;9)@GOWVHet$~h zA*DFoUcY%hCML^xEh2iu!gik@^n_$OW2qjo79`}!#no0JJe5v(ZHjqLamBr8Qx7GP zK2^-k6-V{=h|fAhsWmH+{g9(njbepLj$-l%7nkyt9Abs8RA|~Ii-NM$UYKG*g;=K~ z7$y(lXI!1cnh~+i;B$f6CpeU1fsIDCMJad7o1*6i@x`Tq zk?8_m-iDqMV&OPt>5sR>3R`J=p_0GVqZ6buHi`3PrL|S5LF7*5-Y-?)qxMmhidvYW zP8VZhYP_gSF!xSBlDomT(I?uY`>#;naUL!%-LRc<0zPL6F^zr1iW0HTOKeUTU}CzU z??(7dyBL<{nQXycuhW*UZ_q zVY91LUxbRNoljj`4`_Ml@BhbFbAx)n?V;XQg{{Q>NuxFIdJ9Ed=D?R}m%N^e)G^v$ zUx>Zs{Z`)DD*^ug*)mks6fetI>d9|UMVB7&=HiMQ*H~P2UpC>|=(Fx$Ww5S*w|kmT zyRi27M$lNDCKhNp(r1P=WXV_N>?|b=-|Kt}B%eKPs)|b%ua-O&R24bgS+@Df7OzmE zr@X&y?eL@uu97iJND*KAPKYtOh2qo7f<68H{hb1L`p}hfI*AY~o20tFN08rAqxCr? zmB#dboZ^bC&-$IJ)3o@m+5D-}>qKY!E@D5i#?PT9vln;JnM8GE;}nz6l+#8ZBVs`% z$^zfK_n7tyMXA`H)eU7l#jZPi)F}SAMB!<-eWmh=|JCC`^zsx>H!S|;MR@7tPj)x& z`PjJ6r-Ya)neN!e#B?B2N=k66gFU|+VM2IS+G87@Fnj%5Em-z-C)RFArOm71DFYfF zR?ScdewH*VI_twjAl6)E@EMO4c{!{(rFM_{%-tu}9t)8Rn$TY*QTwldj`%~NU)5hv zjfXjy=RkRoTDZ8C!zcRh0Iv5WQ>we9R;-iKX1g3k11DnQd;Z2N4W@2HSAT6Z-Y~qw zV_mh|hFI_!3c_CxyjxT?vY?4!EZq_%gwJ?#fhxweh{GqxB?_0<_@cLGn8J+WQMvtT zTw6q!;&jaJH&{qffjPxpRkx&E65}8=Tg+GchH; zeL2}ZCrd+YkrE?+lzKIlP;Z!#$K}4qO)l|9P^eO>Pj@ilmG5>DBfRd!+lBvD)b;IT z_hKWVUS^Ni&T71I5h0#TdEj+L%*s*pM7R_uE+^!x+%vsjj>+fr=dRt)$UCm0BLW!Z zM2*(SpKjUbb8DZ^xv0%}vIynZj6NYlw-MZlecLxw54mFF-I9NArZAb3A}^~ca*z1# z%7Kd*@(s%qZc&@acRetqkt>^#BX8jX4RM&_On)BvzMF?PPvvqw@)^2AUhe}R@&3zy zFS5Hzdj+dOP2gX~1YY@n;LHF2;{UG;`+sy<{@cs{QycpK;o>Mtl^5lbc^GL*O*2j= z{WzJZaq=+Bsr}nd?%zg1XlZG^&&~Ds_1#!kS9it1f!V;ofOXHF{u+1MGyAtu-94$3 zs`W;buX@1lOyc*Xi4Ys{pv1%jbqx*IjEy(ky?gi1#DwBO=04i})I^*@tVZ$`(a2b> zl&cx7k3vIlzk7E$x6|s++|nOP8G-TX@#4b5pv6VkbE>Mw_@vIx&Ro|So~5NF;Uh}ZkbP9TwKzfIQdu~1+Lq2drSZR6=B)AGhS%@_H7CW z2Zxx{)Ge#4t2Dmytel)x)zvg=hmR19<1D`Nx8J_yYieoXmX+mHP1-K;YOBa|dX3~` zZA&}pd>Lt|@JGkMAUJ!9E4A?X^IbtfK`|K_+qKj5%Q)AjHr@AO{{HQo&w~eazkmNO zwaPga-zX;-af&N+uYkaon>TN&b#jIp`V0(QKYaM`6)!K2ix)4lva-slsB9M0&-KZ; z(SWCW!W;4UDN)L&MtNXQth)bvw>M{aczAVP-M#W&p9Ng; zR#plN3;RSxab#v^_l>n?h}#d+tGR6ZQd3j5wz_;lLt{fpH$&17S}abg)}HZ+?jY<0 z){K73me`UK&R4HqZtbdc1rg-us#h2RJ_*AcC&HU@K z+<#*x$HqPf?-8?^8@Y5$Oss!pai)+si|34g{hE4UVBnyjprVzPV4dmF{o87uu9EwW zPwD-cs+a3DzWYUFr0qn<33__^sSWDi87USdqo|dsWuh4z9UWc9vs!Fc7HpYWSU#B@ zvl_lD{k>psxboExI&lI@$(nSx<$Q*RZ>mzMsPl!><>lqQNlq?{UTux;{T6$wy0-SN zR*GkuVQemgYAB6z;x@sE&0Zy()ORTY0|T+eIyyR~-@k7YqQMqhZcNn*WRO`PCX1H^ z-@bkOC@N}ep8nQY8+WabbW|Js6}ZkMYOvB%yGU{T8h64r_52%eFWgJ}CO4P0>LGji zCQc8ZzCPn;^`?kw%L1i)^7}p;A9U8xv{0CYR7@kgk0theZiexYctXP zzdl^6ZfNkUF?sOyPq)>E+r+j2s@=iVit$XMkz07eXd?s-`h3ss-NeB0d5?AB?Q2yJ z_wn;@b}L%Tt6YrE5tEBmmrMF_dNM>Qgu_SR}uxNRTRRt_oumoM24GCyEnm!{f4 zk)UxXNRc~JSHMJMmwYTE0!o}sj-F7643CZudHtHFaP?n%^_MSg;hUB-7+jy9qmFN+ zDKZRcZM{sUqp|UwofZ*>YwrKX$^y3Y+Wh&p&*a7p>qU=8L&ICXnP&+{*6Zl%8lR^{ z;c?@bESl%&@|-t7c{mvU#o*>*`i>3zx6wA4A3w~pnR?Tv#_!)h1!isj`}Z$-p_k&e z8*a;8QPI9r=%1N+3{RGjlyuF_ZTI;2__J591~zh|b_6dphIrkJR|<)H_m18?%dMTq zPTHF0k#eGXvL?aN!kFtmFDTTuRnlukyvucn-Q@c9)9I(<7y~QR4>IEqzQ}c%<`QB> za8$%=B&(~d`{ZB|>7U0vO&KaJdma>_67 z)2mS&*p~DoU}?!Mw{wDBZ)Bbek!NRb-}vnt5#3O=Ik#SuZ^!OEdqN{3HbvBiPtE(w z?e(c0kau-GA#(KS)nmI|nF6EWh6Lb_+b?gYx?G(PLx zxpOv`N=0+lWLKt6JqZs#P+zNgnB|(I;||0vH#c{On9natx!HGDg#Fvvbf47^CYY_t zF8YKtMd#=oE_~Gf`Icz>`}d4xSA>VZX4II-pFX{@&`?NdKbBXTpOGf!{d?hFQwQsN zhUrRPUNYZ*{BYfu6HwKE5_fq^bkA=)SB=#PI^PXeIeT1PT@MNf3{;Q}!XH(c4ArEh zQ#@e@Iw#XL`Oqoi+^N6)R8dn?vb8`S|)ac2c_)amYjq9dh?4AASM@9qpkT5)~+_z`*Z*nG@8byHzgf3I+< z=#-0;jl`>OqjLp@6Z5VC3S6wginx=)rID1vH^_1!v2bNyu`U-%!}Ohs@ke4?R?~S= z?6p#k-z<2uU3#xuQC9*R#{+-ON3(84Z{EJe#t-@)peEd0haDuEnw!f;=j0Ew@Xp;h ztDd8N~@1zes zLzSrUyeYLwVe138BAEjld{mR}wvQ`fe^G^n1dkBkYI()PcFxVskxyZE?CiQLmqB5T zROpclhnOGSYiAzG2(W@e_awB5#VCku-PAK_m?R&ErZW5gE^u!r!E(p0D}ojT`y>dp|zX zB{t_*Qxh|XwA(!!@t3*`S7I4CLN?)P^_fskpR}RKeay({wae2m9X9%mhIIe_ec=-) zcFl`twARttH0Vrv0LPF$xwf|U&`tu&EXBdFm0f}==|@fEZC2vo+=7P;Srf6~s5%3t zPC|l$aY;#=7&v8uT+dlr3akxUuTfhUPE#Y5tm&k>X#f6cj7J0?3CZ(Z7R}Gk*Rbq8Si=q3L;Nal*?cEc9&0FV$Mo%2LTS*l*nPbtNu zA7uk}WdB1h4-XGNI{j}8e@I%-~lcY27vhjRZaa2E2A>1HsOLmy5^OuUMa2@4A|v$Qmr zaZi2sPAoZIx;@?^#pX3NP{>a7&tM2|$)yDX3A05|x^?R{iNapme&iX7|iO0jN_yO=>L zr8YG;H;V%=t$4|PHn#1UJj6&7r$QO5$c;9Eb>Wcl@UN(-NXVPOI##K9FI~jIR6aRx zZB{7rxAjykBVMDZq;%xSkq?6((92CTc0GUo{E*nY{mgVsTm6p6%7$39q+K;Pf55Q4 zjfHJPjAeYw=!J_HDgDYrH6qs(P=U$mVz55)NXWE{6uI}eMB85*YHB1i->pA;e4<8Wr^ro33BvcHHzrqEN}oHsFb0CEl{k zbf=ZK2+>k)zV*m2dyHIT^NkC z{?^#|Vep7k0sHd5@$yh^B@D>X7Bw%_58l%k^usK8j;Fk@k4r*Qa=VpyDNt8(GA*y_ z%hKDFo30$&9bM3(^gq~!s)L;QGd^xGlZ&E&L4cW=SuVXLscH29BhCAdAMfD14o*&$ zg;oBBRDOh+9%dgj;)IU7C;s;BVrQ>F#S0`?Sjwuq*z?W1clT72++&}QV4elI|CM9S zsldg=!osr6T)cGHAr7}eb|7UHm8&Ap4P8u&byF1q?sWC^t_k{CSX%mG5zxi~Pnygf zl{;@B-N^NBANPp)mfgchSn3hgU%%ehNIsX>AbR3NKv$O`@|a_OqBGD{-_(?cj*c$d zFtwOYc^pZ6RK0dx}#`kk;OgDf5q{n4uGK)r9syrR28L%@kHRVEXPINJ0 z-p_R$WkJ`~n2-rmPQ(@;Jb17aZ(2~_hf3`Mkkt5LT~lwl1VsxxK}ViFuT@s@04COMH9`Tk9Ilk<_LmX_79eh3B>R4|MevBkwQgSJ@qg{9H- z#_sNDJCzN$t5V4PR@c#~-n1F`p^slkNC`7sg2v4+p4pg_XJ==7nvyTt7WC`^f`9dD zi?95L!~yfuTugv^4jNZ3U20By6^MXlJ7Gt4{``5p@8j!-0~GE6`T(R?Y$1}=q7|K; z#W1~LOCQ@65lFe}>gt}mz5ou>*G)`rnbo_0r1dLj<>08iyNSVOq$xQ!qhSU>Ze}q% z@!xImB=x4{z2$z>xb4X=dBBSLize58Xe)^* z1PKhyDBL6{#-!+xi#~LK2cVdovN47OmJZOZqayX9HIr`|931>9Re4La|CMvs?S`TD zr&`lt`${M09~`%Uht;da*Ula3n;?2yiW}C9cV2r zEinOB00Js^I)_>q7#e={qsPJ;YaC=IQFc}T*}PA(K#hofa@WDHFwW#+mCYz#j*n3= z(NF=nAkN4P9vL0IDjMnhd%y_o9pS+~JitWfYa`Bv3q9CFe2tP0{P)j^e(P4A^A(@# z^(_iIAC8UPV&1XigOo72msx9_R$^!jKvfsbBei;Ld!rEo9VOnJ+UbVDt}i>Ss-mRb zoqk`Zmx}U@#!y{ZsffyBwcOC2af1O98xtVMk71|ZM-`RVmOP4cQq(4ec z^_1N`c=)hK+Jn1$1^{1lbswxQ&sQ0|$Z5vZjcSgW6Ld!3vnqokw;el$3_t(=ZStjl zBdRO1gDkvwro~b+JJ?W&E?v6RU@$Eh3X~ia98A_J4A)k5{g?*SFI@t&QV>5R#}fwp z7Mq>Tf+%hOXaA7x#5PZg@!7wsuT*oaE_Xb9xD`ZITtR{4fm+Hv!HVe0`yEc00i7{l z!05fQvf{Y8wc13)MJno#*JQG(xjCJ*v~;fBfO5wtS%eeVjslI9GqLJ1Z{Es6rQdipHNzH@IQ;QxXUDh%h?Ar0|8>3Z6?(N3L#wP^~*F|;#7?XKuU{Dm> zf6+2Y0iVkb7(=eimoLiTL~^U1hKKJH5@G-xKG3qp0RE~i%i`I~mqD&|2;k7rP>RU< zZ2lp+!z|^)4qRAp&_eCsCsQ?&ZBYL)Tj-?zG`QT!ba`t8wi?j+S(UWQ6k+7GcwNIX zy0F#a0zPYOY|PR9+}o6t3!0jr{dQqA8Z=zP(;P%uID3{hQ`fSifAi+ecxk?s`WeO0 z8gC5Xn~ZpIwidU^%duX{&pC)lW7yVn0yl{uq*oD7Ezw@cH~CV-bgh@Col@?_mL zG-2E2f0k1Cc$A|eHuQ8|BV0vURZ1gH(DT*?medqTK8l39%NY z`(KJ-RLU%_t2?K8=~8TAAsgB}$R3P-{;n&aPyl9-DH>Mej!+p!jLSCFh#Z5vABc;6P2fdt>aGMH?jJesCUSbgaj(lNP$6H zREg6!Zt#N6eqUIK!O=375ez0MioqlO{rzP10_1cMw=Qy}BngMSl@$*xtNcM`kZ&8X zu7T}y<4y%vvvi${1`3*4Sj4{Esr`Sv04yj0ln#!Lz2JgvjUJ)X(q@V30WJ4Wm-gCx zPL0apxLZbm9?ofhC7i{MB96xFA;XE3Mp_tiw15A8r#;8IYP?MOCjm4G5OPhmnr+vv zvet|nKynPAQ&FsP?Rot{1=x|D|L@EUcw1qhyyWC$$C66(EP0>}lYx>wVpcI|b%RF$ z9^a&=`*m8Wy`g;sEQbjPtc{SSY%zFblFm%lq;>SHFg^lO6>KG|b3~#2(AVC|;5}gL z)spiN>I5J<2;Qm?&X5x=*UZdlFb#mZA!*j`$)QsZSqU@C>({R%#O^3?ojl0=2^G7e z|EP#aOtU%0yNi6pVeB!ch?aCCB~YYw{V1{Eg8`kkPt4Ssh#-+cvkk3rpyr^J(w->X zzH885jGiRhQNysLsqR9&*MxikZ}3k?-Y$fbj=ugqa9-fe4L#>B$^5~ZM1jl+#rv_u zx8>ej5yH>Uk;O?q_Dr6ochc+EWNP7hv-9)mu0e=e&=*iv# z1{wSQQ52O>w_h;rEwel+G#zF@k)p0#rPv?vHku3Wa__uHHNN$#R1y2)mB3< zF1m*u1&xmv#9zNPG&tC&x)ylr$BPHP-ei4P{Cgdpty0Pq*bbNWZM~&h&GJ#_@cl+J zkJuNh;HatZ?%S8+$U^4}HW$pA|FVXKl~tv-k*9BOZ|@U+`u*X`7{mKjWrA1O0~NTi zYPs7tMbpnu4{DK6Ks;P|BeaLWiVYd9Y&qd?QF$(0VCpRK0;LF|!?tBTu)^5v*o!9? zs}1$_y#OFc%3B>DOMHEO+qImW`4uVyEJgS1-o0CNbq??tcX^DJZVT`jRCu>PEITci7MIk8QFB#cUmc6eLUV~<$@WUSi1TA+*6H=b!}~J zaw-5O=*YF@9vs;eS$`k(b76Ju-c~S*G$8fMt}rI4zG`b~8H5gG{9*s>+>XEs6j#Kf zyr-x1*Ow=@XDAKzRRm(z2J}5--$3`@Tw5_tLE~2r8X20E9EcjUXg+@a#-Be~X4V7+ z1cJ2~k$NZf^!5lE7Wy~HuD@i^LT^T`yhP&i?n%G)@%yy{cNF7K;wI}p-0GSBcqHk^ zVw0jwvX;Cf_tS`oY19M#VviGp-;ZSJ29r_)BaK{2GmpvqLRb+2F=DIVwTnhc8p%Bl z63z~_-9xJ$c6n^b00=@4cM%Bnso|vPI3y}+2?)hQ+c*4NmJFuyp$V(GhXUc;<)auM zAeiwoDoRmHiv=@0n2LK?E0O{bv+C%fjG*vwiUe8*CPKzOii`||pn}2!y#m7txHl+j zM+X%}5qj7q#rWyuiLp6d76O#|Gm%R_ksZRvkNYBLR%%ODRO)_)A_Kw86&hO0m#lCL zoq46Y>2BHk;q;@d^teCTlKfLh|Is-vOy!vE7&xSKW^zda02PtcAg!#dl&Su2P42UR z!~SgnQTg|w`>7@!op>0IzWMC=^V^zyzRHPrn$1sxX+hRtL#RP&p{9_f_3JCiLwR!| zi1lNLb+kfo<6y$#p+{KdNJ{Xp*F?-OEV$IsTB58Qrq3sTP{0_Qthw9yPB&;kfVPXT zzqH{i6jGFfT{E{|Yx4EZxsf0>Dk^G9hzT!G)W{nac!6DwZ8VEfKlqr7_@7FJy}yc? z($u=8IYk@j1cCEto4q#J{3DW*d*-dxxk9CmpS_fM5Tcq|C z-{_Ba0!6e3(+-FovfBWlvr?geCN=&1*$48446Pr-VC;=D^sJG!;LJXC7C2w~xA)ih zza$R-P=e9=PUGzCG2j!F{pf8&_NYPi_4T#|qN`rATRCN3+W30+Z6q+$40j)A+j=Tl zljk%+a5QX0?4uJ@kyoSku`XVuM$)sgv-@iDonI>AC&zD5Q5NTqY5IcRd<2c}r$6t{ zT3b)Pkmi}6?5=8RYPwjyIrIb-;KZXE6Lbym%H&j~8YLBgduEMi4{oT7O|C5p|)(SA2x87PiN$5;g91#=q1J}jHOm`JyYcl6+6%`d$ z^t)G*&V4^uA#$JwLWe#3pkc>lTI>Wl5alFHhY5VHX0;U=pG1cQkY&sf{`Tt^E3gH| zc#0y!lHsUO1IAf7mLM^pn?(qkQ42t$tsur&$L2C4iGeiB3Tle9fJduHcP>S}5X_x=3BoQ{{))zL34 zFWZi{W##&M1H7OrJ~oEPWbv-iOjI-L0#~RU9(0AyPbamrqoc!dUKV`KY1jf#Yr_6U z89-K?ni2-@#yYQ$26i%4YduCx2}X=Wjfx^pE`EB^C1-(c0Wm*@-`{B9@L1^eTbL?m z2n8|VCq_v~3q3z~-)N<8Cppif`H}-na%*kq1FvZ(`RxG;XH-;{PmUU(N&>1vpR?j0 z2Gsg9JbbpT^CyY^+uC+J|MPZo62-0S3uCH;tvi!-k8VvAL$2~hZGMG;${HWe@?|2#b0 z7hR|~eSIk%Bzo1R6O@Fd7I>buk@Ph>rH5=MQQh8YjlM4Uh2r<&bo-ViEE$)ejoGBN_;H9T{p$$wh?^!-oQ&{)B1 zeoqSvd9q0me4gt$5K><2%iZw>1$zJ_&t94}zkYpVMn(o`{L(~~2xE?QHzZg#o-Db2ho+7cO0@*d&1mc+xUjffcar`%DY{@kYIDUn)J( zz_K}D#TgEjoc1EC)43qFpwA1L&J`+Ho(_KaFfKlxvShK|T3AHnsM0AoEeyaDM>Dq-xRk^I;4CUCA`Jl(9eLYlazA~NCts%jVVm`@$(n1~eO?iujjF3l zNuDgndpXCW^eCbJ?x!gTT=cmhjDxJfl>)Ad=`GE`nElxcAod>w2I4NRd4<5il$n{? z1KW(t!qnAIIUGqzNsS#HK>+ffQ&(<4b_F#6jw+!z=TomRBt_$Y4pMs_;)}<{pED|q zPa*{&ob^vwcH>>O5kkBx`aakLSZaE{C#pasbL*%tf@lcx^_pORFga-n>zuxUe& zY4k}%6lN=u0*4RcRl=c!3PMg2=B}Qp-g2_1{TS|eJn^ymce+c4XGBB<`ruA`wqqLW zGEm4RB_#*vZ(3SbOnS{rW@cseV(y?_7Jd~OdCJ;aNM`Z(c8~c^=OZDtH8o_uLaN!! zP(GFoa~SUJvjFRii8ug!+c%q&Tu@F)YAreW_J0IT^x7=1$KpT)ZKrz{XxD%MTGGd4 zv_5Cl*xtTpMhB=8*^Y>R#xILS0VL=*><_+0o&B5bKqXj|G4X_kQQoNL=pUA*#FrGspit9QV;#(}cp2|G0SHslPUW4*I%PK)lA z*8WG~5fMkJgb8&8aFGw$C8^U}3KD%yML%{q+uAZgk(@YZh60WodZ%aMi5mdbTZy7# zJNh&A-fnF)hm#;yNCR1e%<|z0*0)vtCjnD2hKp%#Idb7N-AUw#<(p+$V;dVrjJju! zpAgg%l7qAh0|q*gF-J>dZdZoAA(huYEM&Ay); zbLI@d;r!Pq%OF)?9Z3Jx+S=s0jFrX6@0u{sc#X7j1qRaAuCBW%DJdHx)^{3tFwHNZ zH^gGtFwHoTEOtWoKhN9P>w;_$<46l(F|mlIFi!5~VbCppehi$IFDH)xWNqFlh^bl8 zxxwAZudb8v@}&3Q-!*^kRp>y2SA8jd8)*$bKex!g*T$t&owZfbCB?l2qzy@A8X7*t zYytQXB}75(6VXz3chdzOom@)>whub=!1pQ54`O3uIak~dGPkKSaemMVf>{wp4sr}Y z;V`b-Sqs5rGYIQdDX$Xm^6)>|ZSVx4kJ`ZfwlW>!t?!)k5#AZV*DFv$&t9IJY$eE% z2L0>2_xR_}RIihgz;ZnnC5ZJRVG>4=TXFQp*UwV_=1bN+GjB~`b|Lwm0SM&MJOjuD9c)W=J zv1Fmq(IsH7K<2!B`4Z<709^rCTE@9pXs8-RQqYFWW@jBb{JzhPw#1ijhWaiK@{gIW z;^B@TmoHxqU^#!nbLp7O+CqOWgEs*lp@KiX@Xnn(8=jqeoglC>^E=Q493Whuk!$&7 zWha5`sti^`>CurvZbRL)h4&8>NLA-dZ~@N3y`>0W16i@b#9D49dTm%y_c0C{X4w5ge8UijEB#CrKM1>l`azIJ z(^I{t;P(7{%RmsO3z#&Jm~9tYlG)N~XvaD!XEH zO4<@k=fb@Ax-*KDo$9A5&5yoBz+f$VwK&3qT0e;_cvX7OK2gs&cE0pvho+VE`^i z>_DRwhjS zC14U2Tg^hjA{lKw<6}3D&tdbHU=ql@?d`of`|lqZF0cA)N?;;tc%yM!oZGi=XWhM9 z9!2k^>QHva9)5m)+{h_y?VVO#g#ka3)cWh5ABE9YXWpXQZB(FeaWJyFrslR{e5?Aw zm#7r4pxSW~_kG`bxt(8&q657`W)EPicg>$}YxmLW6BF@(vD> zSu*=!Bn#$`G#RkcPn=1wSH%!k!p=pNoDF(z}#|wd(8|>(p=|%r`15t@eU3Em1kp>eI+8pg| z063(EhfXO>fb90C+_Y6ey96c`YG8WXk_jL%fJTUfCs73;(Le$P@w*RZlzeOM`FC=A z{ePDE-%4ZN>PHS7>_ePvYziRVNF`7uLh!1K3oB&(Q7;U@zCe7WCChz&!neHlmyJ+8 z_()j5m~*Io8qGTXarm4$^$8s>E{;ng;S4=-NC@=+TeD~6qx%JnQ@Bg3;|0J;sD9T_ za>}nuptWIi#@BHIZv%GvfG*{rgNJ6y;8`26D>D%}35$8Gx&p5e zE!7>fnm!0K@OB4bn#jo^xh^?4?HmsFFU^haSz0YJ6e2w}aD&9dxC)EPf&BbvdPfK> zLdVJ=Mr~$iX9r_y+Y-*(OgXuHAQg2U^m!S^S2$7oiapSBZ9ue=_X_F+uJ@B}ZW2%n z@CRrcqdxj8qJOqHxNsI&8?s6u=`?3xAmJ6_3R7)GPtRVE6D1Q0CA>SpUO)ss2}Tr% z@%2PIo)9mt+Crer@r9c!xqUjedkcU^pVq*BYM&>@S-K>Zo~K{Toyvh${7My68!P*y z_jkDXNWJv@WIH=8MPgziT)jxjg>ud{AE1J?kwO zSD37%V2cr*Qf{*}q+bKBBIh9Q)QEKw<->_#Ot$HguJy|(EYDuNkhiy=dCjvf4g95t zI&u=mJJQNPA54TqWVYQB1sFIPtKO4SvMPYGoHb!{;UKe{`2bl%pb|rY#6mICZNx%l z`l#+FAdO$Ovf7I3jGU$%ae#TP z6h|dWxrC;L87o&+dgAP92x+Dn<9~MdS3mOv{qJypCX9!N$7Z_kG$PdaM}-lWz}qu4 z0N>)SfA=Gn3wpcLPaAtsj%o3D)29jtMa0f&M?8y zf=tbIn;Ut-Z;Yu3@dl8S?P&Ntqk#t+R8D)a+}`)s8Yqm3Upuguu(94(PHbzOHpRSG zSSa;U(s^Q2=PPh%W{@AC>*Cq2Wa)~{X#_FPlN6;^iZ9Hs5Zj%*JtKs@zk#1V2w51D z87OoyP2mHVgM)&4z-;J=yxyH0b0eb_j!@JG06L(t{~6{n7QU46bUU?&cbp2zjjRRT z68qr;6JiPh6&V$Vf^Y}K8hNf^G`+-dh836z&>RN+@jo}7pcos`r(WQY7?q~wZu!L8BWHaZbC5K%gs#*Eekd&V30j8Ix^XzMZt0h*y)eJ zRt#LZHK)iMV2o)1PS4Ln(cTYnRG@HbeiZCP^2H>G*L0Z2HBKEU_3j>nh(!n993D}q zKtLSmG~HPNHxV#saQFwd)zcU>kg}S5&Gyog7^+~r@88;x+-$z!QOy24a<)n3puiR+ zieN;b5Dm3YlWCT2j;HJScMjG$?fqLN(PB11tA@<%o)*n?Xo~5kfSVKtv=B;iG7<7# ztYz;u_&Pb?4?!2~E=dhR$I#@v^rh%?)UAOnm{t*27>_FNKb1>MLIcmS>Xb)MKx)A4 z@?|40#&8_U2&sbS>@LW3)ZE@cq2f}^ib)2W(|~z+NaMm4VQ3Ds^3qyJOOh`mC68;r z?M{q2EkE2ufGB|M6yI3AlZsN#95ANfW5>Bz^-^ffQ1{Er%Pm+a@^JD6jSqGQLo?p@ zpan<+q9&hNmM)}gq;YIs-Y$}a1#Or;mmzxmIO%Hu-i2y|pg(k>O)RSVt2ewT3;)`! zqb=@SK8)*%{3Y8pZ3(mv{@;yy_;L~U*0S`g?abnc?5Tq9WWvHNUWH^JNi zJ2DKfaG;X1x~ZkNRIiP=?%bqy$x`!EuSwvol@aX{(t!>yAf80o?-2afAf?3Fwql7u zDZxw@lb$Xlk@xqsmbO$%TH02K6^I=WsbCv$1A0HzbqozF7Z$`hU@x`*Zv6YGc~+Z& zt0;)1EzNuPQTrrZ|DY42$>(etcqvgMprb<{C}H2=5j+x#20F~r(^L(V88}jecL(E) zQ6&&2B)>*TB?d;)K;01f$OBbCRygp}Xe=8l&mSuNh1(((67o5oi8}K|GLHuBGV9^6C_}1MWLfT$2+5Li3=k@E? zaka}BOfa93Q>OaC*(MRmL=9RfPQLO?o{fi?>2O8`F@#0g3;IMRdNMgqN6&HczWhFt z>YI42jx%NO$`@!%p2Ymk7p~}h{Fb%#Q0*Oxy;%r6@cVMfniIN#$26*ByI!;-T*~{W z;dkb;nvNMdyNJ&QM~yK6%r;UPa`R`%E+~W~)Q$xNiEbE{zyOSATct07>Mk?>ps=|oLD@FO zMd$aMGc=f@%qK6*VfOQ~9|E!~ZgZg6=Sl+yMX|Jk^N?`FA zH$#0yVtC0@U`}-ZKF|v|;-*oz#V}hQGXcgBhKSmYS-M;N%BR1-T&`Z_4XeR~2SNvD zN+3y+XaKSMr)b$EdE|_mX2Lw5T{j=|1GC?hVc_llC!3 zFF*NMf7K?>Y%SU&#QvgJhu<^tU*`{3Mv;$CNZ4=cJWZOBx{Hgr-FFZnyjxyFG>BE7 z;%U7H118x|0MZsZC%ybY2i{t4C`4_-(m9+Ru9dKt#E}xPy5?CD6LYQOKF4HaNRoo2 z)(4|D;p81)Nb6ia>$ZSB98p{D__E1k1GH`O8^sHKn}NYi zl{Wt*Wz(4k*(!7|o1w4lfI!oC_CLcqke+p5MYyE~UewAXIKbMWI+0|@Tr2Z)>kpCA z2_UYEc$b$0P&4^LA)qaKn3XtIW`c>Zr7IuwIT;FPxPNFotG3 zcE~;jit$)m93maIHtTDidJjX5p8Yuk=oJX>{N!)7QW1mKD^3uS zD@u7knx~9?N!|gPDbgG_o4)vXu`f`Fw83LYevQtpeZouxY$6#-y` zSvx;pf`ldYS|*r6NHPdb@$X+d)$i>Cn|`ALkOX$?jp*L^5Sg~-2=D(O0?tmDiJQ>; z@lH zot_-)!j#Wy3F@P$x5CuC#r=A0?H^xhB?tV-?c85*DY99)z+9VuJni4&wJhD?#x$q- zMsDd5&Q964qBU=W+jz{!n$kv_oIJ#eKZ{`yV5JX-g%+j`K$YsmTQyqU+R^G;6L_4XviwRlS|$}Zv3hh)#M*=!yi4-Ccjs^%BTQxxC9OHq zx_H-xJyIJt-1cwv4P_z<9ni0tj(-k+{yZ5b1OcOBeOTYOe`QWfPHud#gFn-%Q^K-6 zTfjJtW$?ij;WrROLDr^ZW@>^C_V?#sw{{js_9?5X{;aO{@36}GnPyP?IBk~%3rOO( zZ{E1vy6^_$v1ME4@Q2f7uOnMV@d&^7ZX-;Gn!PcZDQvOz6_ag+OU=#Q-SKyIC@rV9 zkx@FVc6^eOdf)?(Y6;v*n|3S8_g*gsigmo3lR>64a>W7Ssi!gF(Iy%lJR5*|)2rRpc4M2znJ-hDQtEZ1ah?#ZJHskc0fZ($q&@X zEW$1M?OXHj@`9lWg@yV~1qPy#=RtX;VJJ!hBP!p2%pqp0-zliQZ6)68*TjDD2@3wa zzh%$s#OjtGf0tLm?!9d<}XwDEN+!=&T*aiki{6x9RC>u#|kmm^KGf%Gj!EhoCWVbR=Ay|zME`_CSJT(HJZ^2-mU;>XC+qHi z?H;g8#*q(l{V!b#Cocue@Mc8{Fnl*F>$g9D-n6y1XIZw3T6X4N`fpFBB=vx!I`iz6 z;5uqSIph(b3l9s62g7p{axv46p7J4?Q|m!Qog3m>)6mdRVf-Rz7dIRnheSoSfRXI- z^u1@qOS*+Zs^VQ|2EpHXR?dq)6JRg-F|28Cp8*`BsjK@IJo+#i;mG)S@JIt%R&!%x zELwT&^(`NYJu(KVPdR)9t-e8SR`K3|MUz)&X3_Hu#uXGc(5q!QZDM=EB%}|5@%oagQF8|Qf>gAh-s--5)pRvbi`popy*F~N`dHM2PQ96>+Mx9z`A%66)2v~bzJ zMXi54TegYQto;5KzJmw9JwBjflBJ7=aVW4tWT@PyVd$c>a}LPtxrmaL(>TlXZ1w|7 z;AtF(Z-l`u^J+Qu<-+k|X5C_n|S|DO|&C1|oSDt!-2DShH1 z%3M}vCV2=GqdU{0On+bBlZc2UKwm!;Pux}5Q&z`<&PtO69Qtu|a4;MC`icyYOB?UX z$3DWsK?-Z`>Po}iWo2i-jgLPC_n9^dJidRT#^_P|IJ&5)sc%kBP72Nh<4hoa`3C0l zP#g{0Az+ZNi8W$suPK6UWp6=e8X5;c#OO$K3KrIV%>L)Q%keP4SzrrvKboBV{Iv$> zA+huza*PLMxe0qUms}}XStGJv{7QKSQwtvZ5I6U!>FMc8h142wau~Z+A(}!x4@D`c zG0~8hm)BHdoLycWnViIdfk>ce$JHfA@^fd0e=Gs!YobD;MP?s;#ig2b5pYHE!!sN9 zjcA-$1oVUF^@Eo$#7QvI)eJkl#iEiW7r)XJx6`_> z&2S(5bz~U_kj!vf26O+~YHgzwPpH5Iy*gw2}8}793Rxq=PBnWAWoeS5a+^R6U{s zrO&bV(?&=b3J9-C+2{sd^d|L#`{6XcGzYRZ2F%v=j8;uOy%c=LXn%;T*3FyAkYaf} zqbiIWA-2zg3?Ho*>u&lI&d>d}X+a@+27*Ms-M|AG{Qd(8?+v9L{WFVAKYqlc$?ZP+ zihHh#kB3JQOAmSl9YgqM_Wb<3>~yU~PWz<`7h(~6c$D?}GjjV==eKsZcxURU;iNU{ zN)n_meF&+I@M${YREAe3mw%^~wxeTKno)^W2Pcj$ROanTlVS~++4$)=mlQ_`Dxg(o zej==-qhPT}aOhX+k!f`9NfpBduFJ==olL#TBpJeC8wN-m|eP;97 z<%Cnk0{Xeaeg8H+J4m%3EpOmUxbnGkO@*#ACL+%rhQB9P$u#uZRO8R&e63XuSrl|L zOoPdgghqb-#*IDN{6AJH+39?Pyl>0lciE^wWHGn4*1mQv9z#cSXJ;yUQ)iL8*oehU zgI2HoJ0t*rApU5jTmm5Y($LU?rM;xC9s^K#?L_anmoQCCq7?mhD!|r|1q|%#aWOF@ zc5m{7Qe2D6bwmL0hlFqk$0QwDKK zh3?+)I4msdR;x(Se3v_Z$AzZ3`CDx8zt2NXPENbSJVh1vc-kdxI|2Y?IIg-RQ5>>aZ7#L4(`+S(B)xIyb{Z5HV_0W47R z!_ zHlofS`Bz30V&yAV>DD!Aj7HQj6xK3&{Ht_C?e%l|nW$Om=`A>>6Xm+P{8T_M6I)%h zwlu1nWgZUX7au4132h75&f(u!>se<3DvB##^W5g%K7Rao7U$g$;>2*A_Qrb^RaGhP z-+w!3PGSh)3>o{6>8Yug0B`U@3zkH*)6wa>)DT-$)NFmYtd+4iA#U&oYm zm(u8jpXAb13Dy_KujI&iEF~qZ-t_2w9cc~*ZEpL|=tBspn?afALdh{Paw%G=8dg?m zwqJcuofN-!fAsRbl86v5S@ip%zMp)L@dYydFs6)YE}10PzL@0f3$!BU`LKSrjKu4;XTUD4{vxXhtQCT<#n7TesdJ zD$tK_jU~b|XuJ~hFeu3E)~z~L3L3SM84Oq)96tf65VV7JgOV;Qi!8;fKfZrA*8D)f z>3l}(JF*@pCIT_>oj7qB1?@p_@Fl=lCYd6`bS81X|EsmD4Tmyq!}m-I6?&zJ?4n_O zg?d$+3fmHcjG`||R=dN<#zy(5nB8ooB$Ip9DUoQbYK%t3VVH;-w0loo5A$8W%02eiOyqhxew3xOsRi zU$cLBINmEDpcH!1V4NhS`Awi#gU?6ED2*&FJx!7y?%tr_bl-Q^P5;X0{%a4}r8?<} zV=Q-da&bk)PM^maPr*{=y%{+}CrB!G-#R)dD%w%(=HjAN{In=4Dyo26`420w@7B?+$PBVSFiAHH=`mKW zF-&~#o=wl2H#W9%a;~Rdxd}M&Z{1U zJBoMWjm#p8S-TBVH2uOv)~Ln4CU#o$X|`7Y5OHfs-c6d*(ZW~;sERi>LI!v#s; zdRsmX2}>zG60+h;+fP`fH^tzn*GsLV;9)I}4x}I!AZ6(J zFo_NvmFpVEozl#{?rgAkaxza^cxu-|oQZ-Q`}E1U^+qNpO}k^kB)%`W)gGA$-_yu( zh1-HMBn#5Fv%sjeXDFWUtnQWw;eL=7&_SN5GMdAxe{X=h;i7MDR2euo<38faHeGOo z`#@J43RRKhRfP;?pilIUztp$NdT}Mu@r3t=n$H|*5kka)O^3@nl;zsmLoCuM7dIk) zFjl;Ora!eIgI=~RRK*{w6V?IcRv)rK^o|Ez)8tHqf}&y(fEjw!+VV3ais1AQAB-ow zig|uO~rO%_4lX0Cm}7Wcq6?!2L}4rJ_0s%W4>3|8ktiFU62l@Z{`>D={%vV#nY|g!XuEuT z@?n@PBZ*JnL!_5ht8$b?%%towxofRhExE!2FwFRSoOCW z%uM%MQFsAMQg%^_4VxX0atp`!f`SZvJ}}R8YU<@r;*n64+j21+R$m`@imASwcvPeF z5vdeCRkBqA6U*F7rtT<3T)|eOn30C2rnP>3Y#%jLQtL0Cd3mS}ov74w)K&yNv|PV? zef3DaM)k=sHkb2f{HC*AY)=Rx}LfI{-`3_TV}EWn^BfnQ!zEz*LNP7&(%gYISUpI zS)vPvlLsoW7p zF|K~e4c+US-%^MrgpJ-0*i%$knEHo8BysWwpYDD;{03yY07-@Lfoc)_3ooZ~bRpWu(!Hu(wiB!!X(-hi?h#Kl1{ClpKL;1H{Q@l23aqlUbpaXw0F8qk$|(g?yMc4eEsL!%1wiuP(TA@BpPaXj zLLjEx?JD`u?7wOwgi?*1V~<+2aWFS;{aqUz%59BHB7?^lxZq(JB8SJ9xS(O2MR<@e za6!i~TGUuy;NlC}>a)$|h&g`UmmTr{SzoJ)ZXLrR#LHPel`)qqOUT>AD=dEUQR=0U zG)9;&^^!_FOGE|b2SM`as5vk<7u-ZA&(4>=&^n(pF>EH z!zGqTvz>;e)Z=`XQO3yBU$BtWdYI`MSbE4`OC6_1_kC99z~j3)AH!(OL`Uhyf&I3h zuiZAO6GLAm%o{bI6&v;@QjPyZ);`R1ql3TwD^1#Y!I|;}gp{QoP2Ir94-$&ytXJ62|)&BRylJl!< zpayL7yho!>x;T#5i!!-PjB`;adBL1dqJ2L<`HsDq_%Sr|OYl9GoUD;)aM2WWZBYU@&g64wqN zZHSJ4H=W7B8k}MSbG0eh80MRlhju*uI$X3WgstkIb?+&=-v} zS<={}bBUI>X5wg0e%KYsGS5+|!(mYcCT8^3)l3zpWWM_CC%(7tiR88OFy3^IV21sv zJH_YtNGQvWF_@cjM93-%?^HDtc5BSfa^+&Dc2NHj;Cssl z{I2ep`U!1kJ?zV2k%d&cqHyBJnu4(ToqgBp7tGa-eStk&9kbuE`u)&}=cfJ{+3Pmy zNbc6P`FO*}JpNCndBo#LUqjE1jlTtK)RJ`6nO*y?Ri2P;Zm4-p>(JMue7)(dAw=$$ zPNAbHeA}u;g!h2k5}J^?pFQ^VYIcikQIeaf0eb+uuGYrHuey5ZmE_}rx7=y35+$q4 zwgWnHq&CO0`D4B1j+Qam#+GSXI)B#iuQ4+jM$jV~2}=1+ym)5T^YNd>`^#l`*H*`= zD!A(x#)y?j%1LMYsSPYy&g=L~KEo%wE(E*qCtmql(|hT}OEJ)4%8n(q6<0p+i3&TV zp{+0ZqrP{ZfaTs8hTDH%(P8Q_f81(dj*pX5)S5@au#}j!LFcf85;W9H`zQ7Ysdl8`AONn}WJOOhm& zIV71f5Bpn}zV|r3KfgcT@zXsU=?B|2!g|Cm%bT6(BUZ^ z!OV#N{Pq1bhyO5~*s*s9LF7lXE!eNX-zn$J40S1Ob#lS@i}quEXaDf^8-Fe+rRs zR}h~_80qgY_x<|&`(an>>3i#@&J54L63Du;`$o5<*~7vkx1QAO&N=ga^zt zQ-QVBf?M)*rxQMmSzGPYX^HlIp@w4Ac zAVwSWRRxq9=BfD144&UDg(n}d9iTH5B^W6SM+h1RL2v7iclEIoOH#y+-!0s4?^P3{ zo2U4-7S;unMO?mY;~`eTKJ|#f!qkkIi5y8@I58u%l80yU358H4dI`;KER;@Lx|ub^ z^VzSw__?Go)il3YzVnX<2Vq6f7%PIAbZS~}V$-7@69IHO6M@XmV(Ek((`X0%Ozx~Y z-cuGUO#FwPeJm1AbS^Dr(8_yH8S3kMiXS3w=N&SB{;8f)u@u-(Yn(~o;o&jr=A9~H z3zF*FMZHPUi7c}Z)|$ly8Lgr7BwkVVPtiSO$JgGSSclt^JM?O#n3fVxS5aR5A?1UzoKExB(6Oxh-?!~ScUH!v-J9S}`{DQ(4 zasejghNc52h0iyyrv}q?mLG4&%1%Bj__C4n@183>#3pLFLn3}8$`(8s*b&&tL#46$ zPcC7HA5YKb7mIcNP+0tsyNS+|?m3s;VKePdhm=0Qm*|fUavpXrnT7rh893TsPw8R~{3qs8)y~zB%=q;xfCh26xn!s}Kt;?Rf2g{#>jrE0LTZNWSr5 zqm2OZKBS6?FT#DP1B@b2%YtJqY;Mn0FBnw&4D@DqYWbn82%DzQPr)GNyzRFy!T$`}A_s8ZZu^yt9?WD32UN4uN z?IaO9+rAQ?&JxFdFJOsHIJQwYZ=>=QA%B}Plq8uH-+GJVH3u2{2$?oc@?+mU6w$do ztb_Z>4DBNVT$ymA2Wr+a*&Wj$KYoMCHe!4G1@cRq>Ir2+wvC^Bm%)KcWmj~R{(WxK z4az{CD0$lgw+I3`;=I7hUrr7>|w-{J9~=a=laGEE&Uku{NPsH=bUc1 zZEkCmv+@UL<$bxW`|*&E-!I31qp|-+gA@5^?5|1AYH@koI`r6l!()rAw~k7?qO%zg_n6p|ocz?nNGKNsxiiNe3xl|H4a25QKJAd~UT5Wn}wMkhh>Pr`!t?mg0 z&Bc+CzhUcwi0gHZxdC44qMI3h(+U(NM|%|Wf*Ev}=_dU|3$6ApWLji7sLArNzI7_t zm1c(T`||z!POW5tT=MF&S_IiIli%(Zp4&RqH0WtpXvM=C);TfT>8Wm?Z)t~zN@9(+ zaFhW61&Jvp{RTYMJ@|Bd&kCq}bTw*EFV+SJ3G0e~q4g)vFZFcd&TF{;ZC3RSx~|wzKiF^K7UfKDj?fV&$~(Byn^{V~bK+6l z&Jka~0#CA9Sn&vXO+*<;z3-sS3?0=NPb~`O*pb!3Yq&DheP4!o^^FUAWI`O3BAk`4 z;7Nd1w5NKMrQD_L>`ii;HjT-UiSjE@KA!D@4uKWLetVaS;`!p@;=s^QNc-@m2B($% zQ(+4BJRKf`yP;L_$JBy6?VHe0D}9F{OF1dfoiu zxv9T%;^F~k3az?Tsyx*<$xHKnW|xzbv$MCq(9EFEPUsurn4-CjMGBR?rGd8uh zGUc|iVG(2N{=U-+u%Fys<}d!mHuAouoMd8yB!8s6kB>l;GoOZrhUMYIuX}2v^IyLF z&p-<66?3V+k5?lgxiS3p>mBTzoRZwk6@#9Yc8V{bj@;ukE&U#pCbmN>*-(yOPn!32 zeY`?`VPRN9!(Q@wPM?nAFsLSU{(4_EkkU=L{+xOSH+16t-5s{J$|c z`!46=_wPsbC3*g9XprEIu*1b{+_-UdV0*^Rf1w-Z~p#O#s_tc`Bq;E2>7bKEtO6;q6={_Iq9Y8KgE9T+&N2Y z>uPcl%gP!qk@p_^pR=;0AiIOBE4zS#P4$2Ov13cgoJ2HOWVMYY+ch{Hc%t^aq$C2j zt?BodS;6LI#jjr**w_$9O-8?G+J{xN7Dn}~%luX~H#g%#MrQ+Z<$k9&ZZ}!&ECt5>!F}&mSwDBD^uJH4LNHx?ecx9^A$i8shg&wz z80Dj8d|b)qrSFAw)8uZS@_A9S3_jrq6J;@`J?p;WN`C~?0{57j9y@!Mm!6&;xzU~B z?|CIT$WaMz#~L~pTB#+O{GFaYW@kqr4Wq@jS1!&^ZxhnNrq$NHearml(IdqzTSBlP z#~dB?zr9a4A1_K@^S1A-v$7CMC+d)z2q#0x4m|KNJ;*z3p&hq47S2ZI=5W%zdvE&c z<2P^J8fK@6FrkYxu3>y&%T9;FgDU>7x|6#Trt4n%z58S+87C_jpi!V7F*DHl&i!5g^XE_Wp)dOo;h)%&Xw}sW@<(5U?v9tY_wy4% zO;HqzlIzwR(U*zck!JR~%l&dH#U@t(?{`-dnW@QSP+mgGV2#>G0RB z-(GmJ6}x?}OxqpL&5ElQ{4(2op|0;Nxnd1YSS~y~Jny%;KCsGuV3S*rLdMu*!6<{P z*RFN{YRQ^uI1?1oUVP@5g99B3olw*zQEel2A#|$y&4Z}oo!+Hgp6V&)8&Km>G55*G zx9#)%?aoTQcDz4$>i6NXx?sH1CijqLUid(s0e$GP2ONy_zr8gAwASpASt+I!@OHz+ zY98W2m7~%fcU*A5++h05Oi#4OpKse{0<^HF-b4GN`(JtC*R=7Bbb(m>n{pR~F)s<7abK5q@H^(NkR%9Rh7y z`0}+CCX^5Pib_fs6IIW2&rJ@g1uwmP{w~>c@lyl${njBKc1Co0dqqLCDEmUIv6e%& z`4`A_xi7PBok#zBQF2ia95{f$2Y?9pm7Wt@=lEgJ6Mx^A)uJfK-tFAm!+x%ccg^ymMaw%~y$1d6&;a$~{r1+_l6mM0TxP4L2I@-653-y<$Y zx!3$UuXKl9D9XT^?S7N<2UivJWB`RWolgg5B8<1U&L(U4nEYO7yt|FQpzXg@|3S+z zBDLvqN{R&H0^P_Xe*@P_vRv2>ER1|B3DNNRW9R4?nxrk-zPv!JVc3J!bmW?Np%lMQ z(g=^%n%nhzVG}=F@oZB*7v;4bx6++G3ogn+CB zy{7AA81S8F|487+EiSl2N2S&B?9%vhF0C0NIbfW{?Z?ZA{QS+9a{S&);ZK?e7apcQ zsN$j^-LRuk2J~aTcf*Q(yPAia6EE_s?3 zNYkcytShJaXSMkC^@nj9%g0A{giQ(PzFu+{XFtZKP1_yJzht7#$Icq2i^xM&z+>k7 zNKm*#{#!;Yi26|{Cnxv$sng${d(fw*rl#jibaX_}&lvT~d-9@kE)W5g=^5*~hK*IWuW1jW@KH_tklA0iNBGSjg!june$oW{EhTPrux zw7U{?4Ha;k+VdS5W*IiKrPlJ{c(Xz31>>}fukENdx#9Q|&Ak4{FHiMqz;+RNPLLCwwQmgd9T!_s|I zCivBTQ}h(#>t*>P9r~00f!IAVJW&Qbku`w0)$9Da24A(g(p7AfB5#}^fmm>{`9ytX z30tM23bC1wIc0}$w}1glC=WdVJMe9EEj_X-yov%u7h5NNiMeZjiJGVF%Phq>uhG;a zMu*%nFfgc=+%X#<78x1&@9GYTb5}5}37{f$nNgeF)st#pRVFJI%X@yV+!s)2wKHX( zj!NQA;7rLI6@UK7JLC(VEx9Ym9-8jemGKHtC$>)f&Yr32kz(y#FLyJgwGN>;p(dh) zkjUzqO;(F;wnddoVP)Uh*CW$a!`@Xu8cqeT9hG*ZH}A-{h?MB5t?{~cj#=&8Pj+kz zPzOlF&;I^di5={O=Qy$({jMk0RO>a`pS8TeCzr|m;nA@4acq!7{$=bG0O>zWYQ7=N zT7I3@cebRt&!73dy}eBuhHQYu<>S%&7b8=30sQd*Kn3^sM^5k@{R1sXC2_CF4$z@) z*^oTnF|{a8lmN?Y3joGcYk8?86Vw};g9Z;GFaMa*9YwT1vT%0SM2^lccH6J7O{o%i z1$F^4G(M{dG>(Pp9((>gR|8n^#x6U5f50JO@B~m3f~uQRA>6eU7g)W|QCUbLwhomO z0ECB`0mu@)q{f|WEADFeS=ZSTyW;kmP2TeYtEU#2^2}Jmt1i^l8I9S@qTNw_HQy&* z1gx4_d2O;RU{)#TXz8V;r63QRlGd$493DAZ@%8xl%M*V~OG`;KcKGlPO)}t5aNh#jhvwc40n@b@X!Y0}%+*nJo z=wl~NFaeKVHb^Dk4(u-zd%3m;UmTFawV8Xt`nDA?^S5tzzjSFNnczx7btTXVJsfqI z>HW%<)@g|PUC4W`#Le9KE^Z@9_0r7P31nMsB7zR^WNc}E`cBMDtJ1^HbSROsP{ourOncST{OO7eMpFZ)SA)|5Pu@I!Ko5FqZwYa7j zmmGiO^V9pYEvEmIy$M|T+D=DJX~D{|lGGD_Ag4r{Uq#zkmN02P-&rif#Aq-6YnjFBFq1C@8p{loYs@8(2e9_+bLe~8@3sN} zN<*y?1=ltZeS`{P=F68a6~Ni|G|yk$C4CW9Kwt8;#ODM2jG;Pt1qCDr2w0qKWo8Uf z^PiG?e{XxFyBa&#FY2QSkH~TE>-BwzPeCD}O2iDRZ~oJ#l^L=zBGL3Rbx~KZb`~^h zRW&54ZdOxc&bIJ()XPjVF?>=PU+2gjp+7p>r5CU?+dtDCso+jq3e*f*5Cl)qk-X6v z{%gHOZY#h>enElME!!Ws$gf|8fV+gE-fUcP>DsubUWv8*E!`aR4G3J^LuaG`?h;)` zKx?*l<+oRX!Y3N2t5w!w>A|NJP6EcF@bURs>D zw6Ss8AvPCrw&W_p3@MB~k8IZ{E-r>70zg`=RBd?B1DF)tZb!;KKp{IsV4LeL1^&&% zzLCvBQG#pM)Sd~RKVtpDlf@=?`op0`Hoe6*SN>C{PWev#Uj1tRk4C^mgX+xUNN{xS zt2tJ{Ix>`gO{&{U&mF9^_|VsP{7H6p#E{`S!`_bsxx0`u{YXFvWk_i@-jV<&*!C{^pi~w-y#eaH9A(irsr$nDX>|Mo7-uC zC}_lB`@TyH+g{E6+W4)^Ux0)wa6??>yLYU&ZruW31LOx7M#D#VNwZNZnL~6_bjoZD)sqb5_AONU| z&%e9jN!^b%mAX|(ZSxIl-8iJNj8dDoZHpk02l}3%pdi&C^~Ue7x^2y8Ud=KF)24%? zQG4Cvv_&`ShP$dHb^$`Tt4U{FDWUDTh+gzh5mHev3%u>P@Vm^d@lerD|N-~mL9k=_$jqSm3BJ6leGInu?~*D$UF9|-8_ z=^=|qXQw#6({UB;pA?{?qB1(rB!#+o+f>EVbyeifm%d|kruQ^ocXvxdqx?EtG0lRZ{ZpTo}rY2%j4NM-qml$4tG%Nm!=W5&`(rEV3DJ8b2LqBB_0^2IH`VH@ zf*LV~w?bk(H;`;{72DI&a!}KElt2ao-o-@Gp*ejS_D0&;IXGO5+6x@(i-O_)^S{-A zlt#%*Y+-bgH<-yPjvLQU8Pq?&Fthp9;?xmDndF^4#umalmTZb@YL~a1cyDKCMrOg-J3@%*I`*+hcH-P(QQD9vton-3+ zK^0CEBzCDozi7=lTJ_-r_Y;dO$Ah#f?ZpnH2o}ZFRs9B#h1hn#(73o&(y>N$E;A!* zRy=p_qAOSvj6 zAa@pZ<+^d2^hVBy<{O}ue`swD&&XJxvjQ?y%%zTHIk;6TK7X zpXB5?oa74NvHTw|z)6-S=bIdR(`6VmgO@}AqJSJ@hM7_CAU1WY9F=DTLs#bp>&k77 zgl_P~Ri(~E`INE{1VA#Ee*b>*&{S8y><_su&>-Kx=Y;4j?L~oXjY^CNpPQdQZ>|Vb z^%@FjXq7#xFZ4GfIvr3Wt>oG_Z!W1Vn0h848_A11e}46#r`6LfbkKBwQQy3I^P!`o zaX(qbeU2#$HFb1EBSdf&M#W1tJsyzf?MbzGZ~DiNA9?R-2VVmSMU62spg>*#8@v=A z4i$Ud;5AWv!U6sC=GXlb_5g!2u|_fPf}zZ`xU2%_1w_TwJL)xOa1izN_0TeO<>O-= z`E;Pc<+fIhOh-m~qrDF#8Iarh(2Ewfl7`&R%g-kvsETru6}Si4N&fuFag0PW+6w85 z?g9bPRgjcVeq3|^ZveFeINPy`hH1gSn*Z}vu=*s43>Ie30bZn-W$aSo_}66=6&0xf za#=0!E+MS^O4DiXYF7a#(X@>2EkT?Gk5o}r-K?rwn;q==Yb`H}oPxr!bLUpx*<!hE3ar@f!OU;n}vRd*09khnIo48Vm8@m|#&K`~-?jwE`1u^s? z_lo`^dB1yXZP$>>{fud584WM`HgBEP^5uj6mn?Q!24a0WC3HU(cY}~nQATX!uGCVt zmXCLgqya5GZIi>AkT|s~_(33U63jLVWgQZVQ4s@XXwWhl=qM)i-1J1*YRI$6jZEQH z5_PY&E4#}95b;u1H@BxZBBlmC)xo|%1Y&M6B0bzotO12x9dTo}CzORq+F8&h%|ajy z03v_|t!GXDZb8{5w*;w%gfTK8Ak48ZOPPpAfO}z*2u>5PYhx;72TBs5ulqECz&?S1qd-GOTK?)*VL`zP6sOv=T&TU$}3j?Arg6ST2)6nUEyb7P#;xbt=L?EA_`5>hLKCu$Qcd!iUc!CE)%X~x2 z1$r{2frw;cUt^F!O0ahZ4mlPaerUEH)u8S(1+*J*1ad>cg$N2Y$OS_k%qST{cEk(^ zS1trE3otqv<_=OT7q6h0IV*2Cd{74$kYuv%-tZ2zJMFIN`L0R7wHk}LZLXv&!ylPH zvCdBqY7eSAN|&=rVvO|xWI2==x8i?aV|<(L`uN{+a8%R=#E^<&6Dd;o&W^8zhVg6i zg|rlbbg{^a)1V-9gYMNiUTkp*1M2W(useP{tetlG$G&{aY9z<=(-v2}$Q{K$t|jlm zW(|Rxhg@?ZcP$?Q?HYW$uS;A1ObS3Ot}n5{Sc#Ps(V-a?T49LvhTN689l^ocSjoSB zWzuW}Cd}v%m>MT9Mf@;>Yyt6NLtISnfUCU*aVAW z3mwkN*P*M`_epDX+#Ps^$p$bUS-{JJQBI+UgX;_5#y&As-sv*?L}Q|YI0tAcv~kNc zoh-=DmuO?Aaublw5zVG#q5qoNIs`lgVFuTQDg_9S(CRyTCu+}Me&hujI`n3j!UXB# z7vhZ9;?{lW$RvCR-NaGJh@be!b?RxnWvRd_U@>P)t18XNc{ zks`n$Otd^;a*XPiHMuX(5hMP`bups@u9BwQx5-V#+M91MdYVcdQ06UaVjUtl{glM> zR5<{mQP@gWVYpzTf$1{iSWH=bXe?!ZZVt2UwhG{! zguzKaWF^_f_z7=yQKSWG1g;L3ucFkX@f8Dd$s5-lS?z)elpJd|aJKZMCg)HLilMSv z%Qp<8j$+b>o%>Ru&m{hdj)YXMEBLN479qPu=XT;-_Mmtn{ZR+%eh!@~6hd@JgW~Ro zi6O&WI)hm@wvHsaKnLw(plhCcSZa1WWXF`7ewlb(;n*YndOpJEy zZv2y$vV>O0C=(-2ZbuzGkwxL9uUMaMmLbhShRdYi(Wmr#E}N7BwlPvezQX>+h>@Vl zHk3z9-;D@tW$u56c$;3MLEfxCjvjCHnLya$CUYo&J&e>cvYD)9A;65l=whmNUuMSI z0BgpGvB~u%tU>hj_|XTHZ?L>V5Lj0;*Ta&M_m?dd_pP#x%{VRP<0D@uBj#^1A?(%< zGE>bb;`IMkUC=Sh$>Mp>0QJ|^4v=P;qI0FI0`pxUK9|CGJE{7->*I{OZ&a9FB_OtK zCPsB|LFzTQQ9jnNhg%8AHtED==@^rAQ0TRRrl@I%09?F^B0Z60fGVcRuq$%|72P1p zKriM+AHGx)D)4$F**@ZGKSh!Fu>Lsw5K4EXzjI<1fN@O;8+8|=RTLA2r*_#0)fSf$ z?&}n)$;9%)dur?}{qBTW`t1+1yEoGj3wiV5HCWJro4ix>ySFZM40@{3>3A}uG^2o7 zW#4+pM!~c}fKG|{aFT;KTVnl`ATXmFPz33I)_%I~5n06%tC19hz)3C&X!*yT)XIRh zJVwHF4@D^4mE||NmNmvfMzv5LFt67HK0m44e25^kl41uCweUB0pRBYkeFgr>S3-wX zm*|w#!BVA#t|KyhC_rEQbdoX8Cj>r!-nEskA|=>?Q0sfR#Engcb43%k>_FbHgO08x z$*j__u&5{x*D(B37a-dpba_%Loyu(Dr3vO8Wlhez_+?RL-`sEH%66&+EZff zM8DEV@H2!@M?*SQAr9OcxnGexm-j>ktpoiu&bY54a_Z}_3K%JB7-k~E@w>Y7?ir^c z1-V{wg8@H)$S)KHR`S$iH8G%U457Bh0fiKrFH-_5E*#JC&}6+mz+E{9oeQLlOZjxG zTk-x3bEt>;uU=h)I-??wNU(sc1Yk7gyO1RC2eul}UDwm6?SThMtgmtGmQI0eiIKb= zEFls4-AO)eSTOJc(jt;!eubl+&`Dnm>FcTrCp7`B^nH*>Qm`VdA2a8^W_PT&PVg(Sxiuno8XQS1r@p~u{P zC2+YTGXZBLnM`L%V5STsNEh%wsS=WkA=J47MxsgL)gerwkmJCdD4hVeL1SfK2R*Us zB;E~}?TaU;+0@$TH3gelj8!OL&!i*6v+)It)%{lj3(Tz<0Ewm^b}7}s}6kAioRLT;heaMYAgar8-S+chT(TADk>rg#_a5QtmwoeH3}mW z(`HPw?G!KTh>yMtMAHlAB@opJY3x0wG4eJqFN2Rjg$Hn10YrL~mh@1wIuZl)A6B^m zMAEi%jPNbM4pTKVGh?bAlx@L=K7KDqHZ%!AW{@huNU@i;1I5LwNR5x=IE95Z0`r2b z40O<9Dj;Bx<_b+scFf`+GcoEy-9w#~f4GwKdq(S!12-f!pb&Bn16K_6@Xwz=leAR@ zC6(JN1YNMPzbZ+?ht!JLxwxb-1^`xbR@TihU+P{7;fs#I0GRNvdPusfpr?T~Rdy+o zIuyC%yb*b?qi@`}!DIc>mxr7Sz&!y;taHrw$G|IeRSfF8wNI%eLh9+>Rr%$MpqO^h zRg?7Qm8len?4G|cV+ok)3ZLnXHC5ccode8axnVAuq^`s&{&Vd8!`XN{MAZEJ{MSmg zwc>o7oSfa{(0OvGbu+9b@P$BNuL~r{S@2sFw>P<}UYaQ zX9VU8)EwFCH>>y7S=qtanR&~WEu=)G*;V$(2a*Ju>FtCBT?8#)KMJy@SbtQr9`fCN z^4GzIsp9e)SZlb_CTL@UFfWb%@E@^A^PaIRxWH@kh<0A;*^6HQ_mocRx2 z2)I1ZgizfS)eUsdXdxdkPwhJkvy_NYe+M*@<(bNoYNn;{2QGj3Ea3gSb7wV>A$SHk zk~SQl|CkHG^ttCC@3NI>?_N=t{_^rITZN)9C4p&&RI{J%CqR?1|7A7`Zl@Lt{l7Tm z+rfW>(t{x;8T&!B`3?;7CK!P^nrA)*!?Z%0p`e)}EW@k1s2G>mIGj&i$BxPqT^pjk z*sM*;hNHjRwm>QC+!y{9W+-B---jDP|15yH|EhO{oA%)9Y|;Uu^vEzs|Dh zAF-~R-X5K|Ggklq07mB$SLIM&N$`=$k zx{iPe4pP^GP$rDZXF}f<(hK39l(sPCTP7($sk6d9nFv5AWFzG?E!2~mT*GKFvUrD& zr#xtKCV7goP{;X$)1{YCQ^TS7ke?qEBnI72#9;VBIt|({=`ggj18JJtH9~6dSi;Y% z)Yvg$j#sdK6UHV$j*iJXsVPQU-Zl;;zC44zj5>#E2UP?!M?3-PAmzZE)Aq#uSU21P zDcX?|Ju1U8yuRd*E@n&?@QlEGqOI%P=nLLkUcTBrpX} zY`7xPDJviek>c;kFR{rUi{pzZWKEHBkmO#9iqfX@%pe=VwDA zh7&u`5$YUcdV}JZ2cg|>F+kUWu^_dna~A=Z0ED`U+JmkLjx*-UPLenWY}uh) zn;PtQb&X#5PPHs3IH{PASj(pZvUxu@!3E%LuXtV^!yf|~w!qv=xkqW$_O3EII+np$%MQg_kq3{v;UoB}e03{lPDAD%CMVjfYdv z8fFJf!wt>0j(Dxv0Cx#=cXFx*Byl14upB?B(&0A1gX=EYc6fV|W{SSE_K@~f+j$Zb z5-!&E@Em%&#;af9w?W&NFFMLXagLNuv}m-2?QmguAKxuCs+W=topY!)cel!ELkuCq zR3|=tx@kF(F1$vT0MF|@TjCDKAZ7$c^+Lg z%AU?gF8_E`z<14M81=rfUt@IZai!G&(*XaVfTcMPv5!|u z-$@|~Y`kbiM+fB+8y;DPW?IYd{y57vcECL^zQAG9l)D^89{&W;A7p{ zxGXD-MQjt{#5iHiOagGTg6%7P$r}U41UJ8`pQf16qpbt2h8QrYKCw7;hz`gMvTS<- zIC}e5$PMmybJ~Y*x%Mqg^fw~Qpf%!9O7wz{8Kwa3!-ArsanheYG#0eE;_2{LixS8_ z{P(SFZR2|rux-mOw6ZLJTJT8*>H%`0+3Bff8NV~QR!I;T6JvTv4vTYyBWrLaYggY2 zj4y)xg_dot==GK&j)ih5^iDf~11Kb+Fi4F#ico}O2vXx@um8aeb0q?rN`8Px-`Tg0 zPwxp+NboO9Z63VY`{KxvBcSk*j*hZm1hV?I-M)|e^t1!5_=}3XQx9Z9DaGe)Uxe|B zBy-uHb6igb#O^0btltyRHEM4tt_k`y(S2sFT7)%>n=^cE$xjRdF>lARU0ax5<*iC$ zn+B)P^lA(y=?B=O7(=?=YScp}LoLVr0mC%wm{XUk?9n$#KkHKnlOKbfC$0JwG!Nqk z7+>H6jB;kb`*grL|6rf2$gCS--}tP-DWqd0zV`{eUtE2kuA(4c7EN^Wu?67u@>z+h z@$sjQ5$lC z1IMq??W+BEF2L9K`Nng7u=;2&DnO+6K8iygFb$(Et5)APEMljVVT(9knzxA{!bSa_+KWZc+1fP$e;{owBwU?n0|s zp{Pe2Cuk^>7^pzT*giT5866-oK+CpKYmtpYP0~h1X(j;4{nQzl)9=qN1{rBFK(l~d z2g+EhH$7GjI|VSBLw}vx!)@bDmCfa& zt98k;OS1XlLDk&RYPo}(2m%8KOiUqK8@{3%VW^neBreEqZ1HR6S%FnH?3Jh~N6*il zDQ?FLvD`881Rx#i{&tOnz^J6NJKLhv%o%r|)T-Q0^lOV3E{8U5Mf ztc?4FGxn-_)#I;id zcH#N@_e)R0OSKpxZp1rJXt`?f2qmSu{ zykb$bmn&g51K%+nfQ1H3K1bwMv#UyeX(@7HFM`r2=Oio$sd@g}2|trKgN{>KYoj)Pbj+>uo-+afwZlnNmM^{9EUcdUX5 zgNB+1#@_{(;OItx!h%sXH8pi1cl{Hud{lysCl_QOGQu#2==34$vd3DCHCl(Z2R;tZ zbC*XkcPcPC+W;LC`X}x#E)&BjF`SabnURm`OzAr@Fa|XQDg5iVc!LSjv&83{*Ij7k zfJSN}mk_0GEwl=oVR}pn2z3e);Mb&|sRDz!EFXlFZjW3uc!>~~xwr+FF2grI@J4Z_ zosmFjlkSPnvi39HCr+eq%(@K>h>ymlg4UmTQ`|e|+c`at;a;hdIgH@dUeacpE}~CC zCIQc=l_bde`y@pIii#TXcYNPH%*9Z>*k0|*7u;vb{2a=$OW{|nM&-$HIhF-C(|Ytx znb?eteZlp8Pc0V=aZ20Xc-QlC|Lp{%!27)eAZL@1-dm;)IR)jb;m_+#?vZC4w=-$wE=OCJhlbX1%{pw*hqU7I?3Q7O_!X* zJfV9F&MQIuo#Uf^uus?{L*i^+#0MPK$eTNRyaBNw7y;QYYett;dxh0~{)j9D1s^JU z$C6|Vx+fER;|aYd0zbU!=;|ilRvyv`zT8v~LbZpuSz`TXw&f*I?y@aP=V=lF5eE}o zI4CgZIm)sF2R>k20M&RY(eVL;a_C9Y8sfiBgMb3YlO-;>ad#21&ywq7lqkmtNR_U` z@|D+G0!>-eq$0BB+(TKmZ*Yf(SYJU3JlYZ3VU)Q)s+kmfd&WHb9)Cl-#z+gM zT1Tbzb=!|2XSQpp4D0yCw zBm%4t;Jaz23^u^j_GNe~MTtJ*~ z$}Dq{yaH(Vc%=YEXXzDYehHKq=^ZiB@O&KoM31+;TkT%xv;+Dt2#qouf#ikIpYXEn zSJ-zqLuH|zih}0ZlD$J(3%&knP0o+KwdWUu8gv_m1fy%;HQAF1n*iIjUaW&!|M??I zi4E@(N~{7YD3fHN#YYaZjgIS4zf;Ps#kqB4`7Sp0H#;ttw+}x+U-9udRLH0U$pHxg ztAta*VSz1}v>qrVz%Cx_6k<#MaHk=Dv3C# z|tjyOc4r#3eq0B$(w^9l<< zetf>=p4jBUmp!;i^g`bQm`cj2sjGi{BJ$_F*e5Uq+!tO`7)VZU)~#D-?B8h=+fHv= z=65ib)u;LWu=mE{;Jq@<)wD&o;z$HdPtUOy6+%791X>k<_3b^6GLo0pMAwqXIpXWp zG&Oto))6`d;&H|}<2u+aEADQ=CC<%^9-mos(j-P2W0X`&ITy1ZpOEl@l;=F70v$_b zHo1?o&-#~%p?<>Y_sD8Yxb%At-`0ew30g&CH1XMG>t_OU&%+?%z+r$>kAnCsKtv!* zk4$G3F%U#@;M8h{9UmO^9%8%!*e=VbE6reBiWF6tcXk_RFuKUMt&PQsz1W3#r1ysenYuMgM2 zv^(h7)chi5iWY@8u8A|F(k3WxgD)C=>bdu@5XyroJ@IZ&BYcfcXWn5$pkK5^of-4B zZfm|DJ{Y`>9QhwK7KdAOFSsDxe!he4oyrl137qgo-dSx2#>D|$K2~JaN-o{A^*yl6 z<2o%?1dIV2X<`}#W_EYx_S@lLSVK)rOw3DjUOs_F<2mMki=HZbCG&kKyd--sph4=U z+)rFhAgLj%?~{G#GIkeJEezxKexsHCcgj8+UX}83=4Vr? zVdl~4KL0}sJ8&9}JQq(dPERx>goMzeszd8MdY9q)np;ppFX-qL_E0e%2FjfD3+$dJ zddoPFV#sa4oA{+y))-%*@Mwxf%fyYp_%4rx)!%P^dIG0!^ufP6Mp~smy)@$HWuVm7 z)-F^!B0{9%89rAT=yW~_&8h9d8Kl~;UlTIP@Q2u-d}jq$^WhT$ZiPHZ)tY@MVKS9S4=2P|mXX(Wfz3^t`{%yA&sK z9)2|b7OKys~!Oj9G94xoY>eu0ce}RNR;T_wc0K0=Lnbe^gOD8as~}T z3d#ui+bB%Z50<(i)Rl$8>ia&{%+WB!OKrkN-X(1Uhu0pO$!r~Zafbq}1Ryw*pTTeH z_Z>4)kB^HyHaQ+}3(8OOQd@_1qUwore?ZCW7)gbSo@9b^Q-*~@bBjS~k8jvG5x|rH z3yI``$S3dmNrNix`UMLy{;0%G@v+r*^5mjRVdkfuxBptcohRTsgRxfrIP>+=?QeQK z_Nu%q(;UwJsizy4Ql}K+xTsI3@nh!pZ1^XU&Bzb=!50neCz|2q8@{F%l zW$r{MQ$#100ZpDwp*{y~mZUQS&5SZ&Nz zO0&%&WUol-@9z~rk7~$-#RSOPsURb=hf07efH7mQ*s;Cye|Nl=!n;d^O>G3#Zoq z9qu-LDe?aN;cScJ$KtGpG7;Z!Bs`FH;SKG*RhzPJz|5QJrmz&|fqQ?^xWe_AFH&P; zF?d~jFlW$R4VQsmUE;N6k!%-PEg%A0PsYMOs=VWDKFnNCvIy}rPt0~J-KnmtV`11) zF*ukZGfDLBb&t>90y$J)md|WXV$TbUC1+)`xgRGA4ntr(m~$%M5>ryo!X<4HPBD~v2eP!Cyi{e|AoPuXI{<>N~zumH(N0-d@P zvhLtE_osj13=Inq0M)o~?>S`dVfxPwj5{aEH35A&uRW*JfsA8usoO8+Vl_;M?!h%D!+zJFOL{BA~Pr*{h z$TWWG&doG#74qtLqle3CbWSR>p=*(2>=u=VvH&sb$UbiLqvG}`bYLBE#ffR0Xa+I; z{P4g%5#njvlgxDlP&P^gU2%JM)_D2&o?RH-2}sftM^(=HR9IxCA^E{TPI;K2X-k?NW2{r`@~#$}w{Bo+XeTj$7IVr^1*@(dcGuO?@FeOe@`?xCOD ze19LBV*H+hHhsWs970NM`q02m{U!!2D9eoBGDiBJ{MT?x4B=EK}*@4vi@F! zZEnR!V(&|t`kzo*&3y6xyJy81XJZl%vAB8g)5DzC=P&Lnv#G7Ci^%ksK#TB{PHyb! z?9@>Wq@Wt&w=7`$Fu^-`WXl<4A(SWVR>Cu;TWh4zJ-3wcjN*4KAZ0fF@PkiXf4_dA z)xpzWHBf&!lAfuxxRYodt_vn6CRZBlPVsTpT(hd@$taWIB%|(+uH1S;2}5&#)5$O2ZwwVH=UuW zp827VuymXY))a%AH^nL&wzkZvpF@*=ac1vHnzNe(Ru5#mJ3FbhG;A)Vv!8;}J@7~_ zFTgtpXIkSoT8$J?-jc#P&4QWLm3XzpW-KbKWo@UmlL$AuDqu_2Thwv_2z+W z=2xZ*IO$V6x2Ofwj#0Y&S9%m}2=b_T$AW@v3J1r7%A+AR8K*s{J9`;L4uA}{D`lai z2>*JxN%$kz?^dx&qCw31}XbK z93-$7B$_!Ghx55#+S@~9uOt*QL1-Zxz>y;dCU2)&AUR-qQXX1b$3$QyN=vi8vQuB$ zN{Mg|Z6MH!pcxm@)Ng%qSH=qp(DTv<|f_ zmaSrE%r7c>bU5`cPE13c#QY0dUyBpb`~GH7xvlDOFxbGq+2poGQ%s)DP5=3R;L5{+ zE@cSU$Z8x)($^F_H2fP@ghOBHPc=y$T`EBdHqY?5zqOy0^AS{HGzQ)TpagEZ{D^tiPGS-1!5tx$%&%#XX-J3c}Fy=1lU62 zPwju>QAoA6o+y&#&S%bW)j66S`c7F4D*x4YE*S^90Ep`h*5d5^JY)Fz?_P#MS3<%Y!fCb8maT-`pecwe43~GDs%n^!W z(b}yG{cuSwJdnb{00*E^OSxYT{5m!Y)ANg&MNEwEuQ9w!r#$nWi8heho5^Dn+U5SZ zF5-$wdg`yA+MQ2QQ>;H>%?2e2a?TjJGlvF-7P$5QGrgr&R4R!iGZH#DCLN?1Ln@&eayHC&KkW7W z2j6vl*X8P}H}k&lJn!>9zu)iv-S_=it72f0z3s1`v(d`50D^TW9$hM7;A0=odK_X= z;kgJHSwr~{`#fG0RPI3VPsCp_uvL|_Zlnt!3j*S@?B`cQMVN@$joEi{qp(zzyeuuH zK|qJc==)42?BTZdYP+A1T@Hsww0=r&U+@lTu5UrkXq*qiFkp&v3hU1r5MIpr4LJv@ z;aY}Q9Q`);8PYR3I)>j*ajC>>{D;EsysZfB0K%$I3X6RSXZ~5nkNK$9A)&A#`H>1SBcDi;Y5^7-6u#d_<;2zqL^{)E@h7w`Xw?J2MqO`P}g5LMiD_B0T zK0IsheVMxo>%*jO$YtTD6PIHEC;WE9w0_!|K*0!z3`w6~8;h(E&89y{{17BUiq-M; znmSNK*vdthE$*jznri}(ft2IgD;XlisCXLdbh7-iakdOVL3}4L3f#C6?)jbT&PE2_ zx41p+Oz-Vwc>B3r6>HUCHq#}?rK`JpK~#d_vN!}kRQ8Rk`vIO1MchOOGD~|DgfdJ$ ztXhHhtC*%pMsi(^5^iz1@%Xa71wZ$vxn7VbB0IB^o@p-H+a@blJ(Zi2=m@$M!7D7M z_czH~^Xlu>;^O0V6(89o+k<%zU^~4m{WqejDlAmYY{)wGh5g=_0nqh?%?`sy#PdKd z=5mak;T5|%U$&cEEEAH~S|Ta-Y7dgP27~APn-ej}M*+ZidwW;CWE?<$9FAw0V}_1y z`w(dmX3jmmws{z!4c1>RAWO>8s~CP#bh&Yrj^Jd{Rs7rty(CsY)CBf;(#F79?$S3u z&)ZuINRntwOFjKB8TWT!iB%BSi=EYL{<|089jpB*~=1BMn@ zpz|C$Nft3@yF{Wku~;=>-EO@<3;b64UeQpF?l~_CryD1(&o6Kt-X5r?^3(kdqvPXN z|5>F~@5()3dsh5(URq0o0}Eu|!dtNky)}b~RZW4!=jwr|?6IWl%jX1Q0%pyvkw43fbtQ~QOs#N5NmMzO z4Em%D&#D_>4zWa7BPh(?z?<+pG_K1a&898H6x|N1er|08*mvLG|M@dikH6Yv|43`vXuhD;fx~ zSc5VPSu7-}gjEVkl@MMM+3MGmK8AfEUOT`mxPnU5I^bF(LAEn&(1=SmJkS2hhs zf%+4!FdGh2=9ZRG^H09v^Vcj%N`2Z+BI)oh!z+$v3Ud+e;M=*;EU>LjTSwR}w&OB@+XkcxLzS$vTQ)y50}m zCJhseYVi*by5oToVACtVo)8c6!h(d;CEyIMo;(T5ixg9&tg!kFvT+t z{1p7Zr~+21{Qdi_RmHFXNN;OFE{(YFX_}HiZ$O~LEXiqfoL&DU;SE4b2ns-mPq$4G z?gRS_)B*pHkeITvRapDy0G%LH7><^h#YkNKp-)vf7O2;bVT2~1h{g%I8*+!7d!yRs zIg0?Qqi%84TTb*_i&{&VM>P#b`53hi;W$qxvr zXV%X58$jEPG&K~CJ$niaEHl`#3b-JxCDK^1rr7+ zBymW>jV_(-vPT{f9;unlrpH2|E}_d03fLa~{*Wrc^pC~p%h&`wdByn(kbvMu8@dYl zPgdsU93lJ(3PZmcVn87o%s8+P1NwDg=3jyjy8U24PmNxssin2U$7dP{w@1()gM|%T z1z&`xBf*3&&)KNQr)$~X-#+Z`!mLK|fLOb6eq;gvkP_N4K+}+yL7NSL7R?N#=q0CT ze;0T$^<%WubJ4~<8(&_ap{1&~_G~0XooICxj}5#7M-DZT(`YEj*-<|jH}UxuSS-lQ ziCvQr)P=5&svJ>L9N%PZnlk1VQWW|v!|@c`Tyy0_v8<`B?eS*sX|sS0L25tV7uO=y zfidX)V$x}`3O%%$e|1^x?oI8}Lf-ohK6=iWhx8?-y3RFhpzG~pX*E(PG(EGX(aMCX zxNoR*KxBxVf3kSf=Y-oc7Crn2{(n0sTxrxv%dKQayRScll`eIkV~I$}=m&^Fvx;B8 z>%Mo#b`X4QNfgG}J#~^!1OX8yy89N_0<%zkxU;Fs9dWwUSAhi)T{!gDo`pT!@}tpu z$M8`os*3!H{{*~P&)69<@7jOu1l|ke3eZjd27enM`^b*p`yR~x+K)5}V-EuO_Mo71 z-1*yoSXY3}0z_nw0IV>l2oo8>5iYn0I(?&KSwn6FoPhzcvys!cq_t9%UlV=!k(bS` zNcgY}fOcgui1K6UR{X7yEwv&&t_iQi3_a37P;b(8K@Tw8N@ddwX86vI93r7ZA@%;f z%fJWpTk9TUqT+%uIZ#FHaJsFFwwBS{gMh?dzUT8xW^AEJ@NfS7j~jx zHe@E*wL&ySd==2BEqZ@5!wNN>CL5!R|poBG&@* znya(I>=o^z*~pG&f#6qz@2!E>p=`3ydVO{4M)bU~cErWSy-|#EhBuNwiUABACM@E# z?Gw;G$O^PK?Qn)ygMLx!U(~op^pTnzD9Z$*omgm4%2}oPwfZY7k0e}NN*~JMjw~!0LsGxb!MJEANVDIUg7vQF zak_WwXOw{CG%)@Im|cX71}Qdx(Yq-ZY@&I{CZU(ZU{0I{R-4Fl$~+V?$ib(TL?EGo z6eh{qp#570~9(%~RUqjm66M|X;oSqwZ z&zHog0+DXXTUfS4H z@QjKpfwN>tf3iJmfK|k|fGjvE)yJW-n>qD(5d8>ZHVz^T6<{d%srB*M4vVFcEe6$ak!CFv6xayT0F3LTOo!JtIp0v&PY zOCAbVR6RIZZZI22etW5habkeKKbhyP@aiNYC*s$l9q8+2d_qnH6djUD@ZAwd;NZ>YYY{CNh`esbl|gk^hsWl` zR|cSd2_JkQC`SUN&R`JZZ?It|^y7%pe`DR&#QZ0c0I;IBbx4zJ zQLsMUjDE3t@?cXCmHpqr6?C22$%DN?l=5=2!$IORp(f|kyY(c28eFbLKKjUNo3o4@XH-*#TvBKy{vCIIsyc>!1KExk;MSH)phj(WRcVU8Tfo??C6qJ}d? zYI8&Gv3;<=4@(#_aex_~p;6Mxw2=2d*S5yS=IZaYRCwp9@%a0>L+ja90s4A+FY=j( z|6w;yd30O&k|L*BbXQ!(HoAgS6{`n%H#vT;OE4WH64j%m~8r#0Ls#8)1&(A-V{Bnf@O(IKWG8W^0%NQm! z?iAir`0wkzNTpf|{0|IB6K2)+sHB6YDzS`PuBLY8AqEvug%qB}O$X=-B_HewP7F z@+%cz8%Ar>h~Vf``zbbqlf)%6OX;KP=5lUbD5gSND>=;Ee!l{ckZwRg;0g|nRTQjtoPe{ueHxk72^;!mkRe0S&Aa~eM2 z*E-7NOIGJIy^AlZG}FXAr#gdn>fmNNM=eut44W}qTPu_#EwYq$iK47Pwo?u>_2Eg= znz)fMW#=ZbSqG)_KuL$*lE^!G1gGMd#bwt^PY0}I)lH{0Q z7#urgE7@5wW!&>uW`sH8&Fa&5qjs|+^D3<3=k=@1o+pugMM+pxEJK&~RDcKKzC)(Q zbY#r5=53<`llIHNf|`>Gt7+GJ1~K}Hb$Gh!99BcCwwc$^u}n%r{UR#&=EM%l;Da*D zC`<}Ln|G+&^fj^c?TRhRH3JGEwD5Cef1~PK+SJfNYP_%9WTVOP1sead$Mw|4mWb3a znRHbGHaCjc4$tXJ?1VaRDYAu;0?$Y^3-ji-erjW{^f2{)--ze&1zLAiIoR3BLwG%c zjPbUslth;!J)v~1jnzr}A2S{F8as-+}` zqQ$tR9!o(y&-`6;rjo*WV3oatcBPIIF?|O0t}zE%WB(yJ)j29uwP44UJL} z$F^g)B|~wYbODDe=fA#|6|=n}VzGcG{*xLv<#40&N@VdoKFt+g-&|cuSv`{bv)_a& zPU22!@hetT;L3Q;;c1Gn%g>06J~c{x<%ZO;cw0M0l6DpD;=1W`G|W3$*fVPm^_r?` z>$-bHmeXIJW+LXwW8qpPdWkQs7rs&W-8M9qKrCgP&Ox2%qdRtuF S6YLpA*{z$}pf literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c0be7c2b7a6c91da95f5bc39e0bf110908d1c27 GIT binary patch literal 25219 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c0be7c2b7a6c91da95f5bc39e0bf110908d1c27 GIT binary patch literal 25219 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1c0be7c2b7a6c91da95f5bc39e0bf110908d1c27 GIT binary patch literal 25219 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7831fced69dd087de281499d482668c00fae9cb0 GIT binary patch literal 29816 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 AtpET3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7831fced69dd087de281499d482668c00fae9cb0 GIT binary patch literal 29816 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 AtpET3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..85384170f61b549c6ce2b14c918d05abe732b8cf GIT binary patch literal 14328 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..de60d1993b415e29fde1e1e58dc546276a81022f GIT binary patch literal 9577 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

      +T0 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..94e9cb4440706a4427a683bd3b0041a04237adb7 GIT binary patch literal 20620 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 K`RsGHPyPc*$GBVo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e1478e3d3f26855540f3a33fde733e5bcd18096e GIT binary patch literal 6587 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..737f6d9ccf1017b53f511d0e8bee001a5112bb01 GIT binary patch literal 13414 zcmb_@c{r49*!MkSt86_gB3X)1MA-{N3nrqd5ZNQyw=7|19z~_1LC9W~v{=h-Oc9YS zDf^av-}hzS^X_@S_dCA#d;j>3<9q6GnDxG|>%7k8_xqivhnI9TSa+p--R_&r1g5-oUZ(1R;)*XT!5fU0S{mmN8u~w}EIk}v+3BKbQxm(hKS27>OVI~hyWf9Yy7vCg zb^CNrrzzjK?F6&a_BH{HAM%D{mfq=H-#g3Nr%O7yB8Jz)w$mx?*gWmhhIO&gBb6iD zyrN$GWFg_~8`N?PZPy25M5QN+=#0(lwiUvk{c21NC25Ew{IXo$NMX1CIkL>XEyh(r zjG?)x($`)b!z!EqkHcIgW}4)n!i+`bss!!cM}dL)i?imOLe+?f zHy`szWsBWX3J$xdw4|BqU$s~0;GL`Z-eS3Kme0Z@(s5*f$dB|goa|aAr&t`PGh~Tq zDhN^?xFn9WHhT?JjCA*qh5XO$lM10|VM~JaFsrpS(ljF{D?C}pUff04g||&=FI=2{ zn>}nMKT>FH&rQ0J(GtnNrE?NU26cr62vjx53e9@L;^i!>Q02v zSe(gDYwQFm|C}hYCepZ0YjzaDH~SRr-LKZG_gO;iIHD-RzD<~x3l|#uyYL>RnxXZ^ z*BBzR5u!I1yS!3oMowWi!!{EQ3RPF=O}=7hl}m>u@p^=!#X;ANRDpd&8osxsTnImo zGidhW?kna0t8_O*-?2sIk(pNkWTB;UA8J5@drs1V1njopb7IJ*CRs-6&|;akGxJD} z!|d8#qU+-OshN2SS%~$WLlTSO7e$KdsNXIs`|KALc=lg2 zsPW;XUS=w`uX<79f&iY(SWf?rX?f8Wqlwv;^q1$?B&U3e!t6!LF~7I2A{08mig&{J zT@e?O=nVbS2!dncDt^aj#ig3#;MF*F^PRTdIMq>cl~oM;Hd%<_*VpjsacQ@-D1<`4 z`M&)EdG9U#F=O7*c;*vyVY~c(Xba;n?C4eUnN*qKCOUsF=z;z3q8wVEW-{Esn=RVO zW1eDrklK2Ea>~7&5K1c(|8}9|>IW^PceW2~_gQR2pRbda!cBBYdO?X|g~e9pG8OJn zxRWo9toY7G35i)m#w$HfTw2IZN*Hrt%C&!j!7q8L7qp4!pEA)p~sM`3S01@OR{T&IoqMTs6TBE!&mMsjqRdd z9X^CU95Qv!;`SCumnN(N;%bdCcxACdiw*v*7#5 z;}r~jdJADfc7}y}*PXB|bn$+hZ%M-V=BDp-DfftYbay;*XQ(N=FWGUzcaKF1n#U=^ z_1`^a#{8&LgKONpzT>qN6P_Ft`DnLP2d@yG=bCyj(ZLLD|8G37^S&U5Ww?6z@?U#IbW&cw ze(>zD200-?M}|k3mu(MQFso)@W_o(?^t98dQ>Sh@I;Q62JS)4qqGw=GP4VAg(d0zN zv~OBlOF4GPT)1#yzqAd@kF<-gUcUSr{^<(~a6H_-OVKCwQ)_3ZmaXkbMj-7|AXCQs z_cu3|rzYF;g5grRJ_|P2uU}`;B8Ag0@&qy(i*p{9lnmXu=g4ePqyN)mnT3DP&$~4@ zH)~$Fa8f`xA}#I6_{45m_`dE59#sBou(h}ogX5wP7%0_#mTc=}zYrSl1SYo2C z=-zp8vz^M)O>lPu1Fm4UgO*F(PThsDLGDxNW@Rx~DVk0rV_Dz8X+O`FWm&p$YK zDCni&F)^`+E(+S>oUgL8!*X*^lEcH%{d9MCGYT;igMv4vOF~Pgi$g*rDbaCpyp8L! z?;ff8`kpE(DjFOb`s;~$`v75ZvnY zbYX>*HRh6KtgJof`Sa)DX8VH{#Z(N1XMcxC%CQ(B5p7409TPi#{LZ@Bsk7g2MLc@6 zE3k<;8za6H`|{->I6Fpv-m zx-vO7mi+2fV1B+_M`!2Q`1s4bydUzJV~=;KD)1f=7QU}3bmsQ$qjq+7DQRhJhGIK{ zE39p7PWmq1V&hflQwOhkD0cX8;D-+%Fv_#?a&oy|Gp6}Y-4{GPWsM7*hK8cdmL2U+ z9e$+|YEv5_rk$t~A9B3-K~1=b{`Kn@Y;2BvzxAQlAcy7t8MwTjscFN*JtDX4>|9@c z#U@qmd3cU-aGjv5%U&JIZwnEtv`{C#xlv9lv41lW$E)B%q+?>6ZPt>EUw1a!8aF5! zNw^yg1~W1;+A_tG$z)M+agT1)ScPYjn~3kW3t({pOK;8Q+N?rz+(&d?#Ky9kan%ik z;u@GGOQ{9rTaHeYHJjSn+V!ozxcK;s*RQ|W$Q4JL#}=2Da~(V70_H-@W=ndvp02O^ zrfYkCl9iYL(bZK4fv0U{b-19*jaffUd#u%MqP-lxRsEoP09~a9pU=JCt!XcZp3?S9 zEm?2gz9mua8XtPL1CrUIssuAprpb2u;>C+f+p7cOW|XeLa>ftzU}f+R-8*;kLnmvn z0<0;^%Xjh}JF6N4mZk5^^f(IeTzY%${Y%JUWHJYw_xe_vYDRhC$kG0WZQVG9PwtJ% z)z2l2ONX=6=EmP<<-pqS^Hm?jEx~-rdxc)3M>_5 zW*&!3gBdGVs`4h97KU znsB_14w~*#wx2_}H!|XePXr~a$wCc0dh{sE8So|5J8hRxJTMwW=P?{7K6?L}0VFeh}?%a_xF!d1z zJ3I#C!HSosyS4oNl>w@}N=vH>^H4u|@}#JkSn`Jt5m2nvL;30}AMPny{T(89?ASsY z4s4l($K#W8bIHxkddJH*-2kVwf3!z&$M3crtI7e_v(4O78i&;%n=g)jkBEvw0mrUg zyG$lEI1#Z?ILI$wzU<}Va{sc=0NEW{4K?E=uRpr+&;8j3mj7?3+ve@m{lEd2Ff{g9q;nDQ#m-^kir{BMSk3Q+{_8-?( z;=?VKSM<+j;1Ez0ghWL4JW&d-(3$_ltH&pJ-?3}6%bm_Y ze*KaY7`S!omibt7s`!oK#TG)zeC;vR>)nUzP}eFgRRWCq@Z2FGk&UDbzeXguY$SQxG^-JJinIjVs>#23nB6Zh62%LYI%V&XUV1zntdW>L`CkM!`UC=DSF z!M(elMMMm$M?!T_xu~zdr!(Kt>dT{jrQp<)T}9zlRaL47H)&0s&gJ#XQ=LAWbJaC< zb%P;hn}_6kybni4Mp|227hV=-CZ_y3jUd(o-fKfq8JU?jjS0$=9UnuUKYxC2Kf&Ey zy1u?1QCe=ZDqS0nt$QIG!obWa<-hLX=H|BJzTdndYy{TpKKS)7DB?yXcO*{VnKZ8m zLhU1wOvt<#E0+VH^UhSqm!}6$M=SZ9M9uN$%_A^}d?}bUC>>l*Cr&}_;ziah%KFg* z@=n%6wULv(Fwc}4k*41fy(Su{L@`E>)0@QYB%O=V9 z`h_QMP1?%H$+3+k7D4gu=(wKm(Eb^AUo@HfEn3n2$k5QxxmTB+_eMZTZ~EEYU7vh5 z7{Z&$zBO|w#x}vCyV$ehXyEwxZPt%C-&n4lmLaQNC-g6sN5G?tq9+mXw4cK!O;}I zJ(qY@R@QC4tqR9YSdb@FfI$lfo}P|YzAFdGkYcL}XYnu{kOf52{OGID`PmBg#P7Zv z72S=5ZTH)^Zx;ghF)H;w0fE#&56cqEY1m}vQ~DYjuy}ZT0JlBR@>^luW8&BQ0Wgb< zjLhtPNk0-`sk6_-5xrSh{$(>WxYP(?zD~N)dltuZcV&7gs(e#eL?^B&)Rtercxn{^#aawhJpo1o86D+fkj8^n{w&11qoboO_a?Kh z%9;?mQApdD8uS4%OGrpS`bB}^5p4ls;VM0Gh zIJDa)4%LVxT2FO+WNdB(X0^J#QmzXa9xB~qKIINiEM#e!Y}@UrsVODe=7P9!{^P4} zuW{ER&oi0${lcKoL!#TS=$4Xf4}WYm9RR-Y-h%!s zC=7-LP6wePM?8N{j*Pr1N|r{N?|*M@J_B(KhvxN0k@)MKQH7RdS*5Xa*?BLo0uoqY zY;y@nDii@z!j|WANi!z_%tufe(8BZXL#Xs%rQv${ z-V`8es$g-CX%)i>!q)oICLD)+81b}A9}=pvE(*oQofD0ck<9n+pFn~DHXvsmkhZw! zq5g#DJmh_-=5`Xo7a&epY^>Hl9BriUQ=kk`qVM0;K*)exin4&NuDCGV3Q&HnG@bYh z>gtSuKBm6{v^?bw5`sf}uCPVxdgvF+F7JUS;wFV*`0yi_4Gn?ivjJedSJAJ}R0T;B z=)o-~r!>%Y{5O_!{5S6W%DnOIRjA5~FkNNT**0quH+Q0>*2bn-S__O@R|W(I$avs; zDGF5*Anne>ZCys2l2xA^;|y_y+Pg3$Nr3WDR zfPcDzi)KDU`+5kpt$R0qqAo$_GVAQ@Jo3}P>?S0B5>Mwv`R+8GB-aX7 zd6)K^QO}+|`zt!fvAZ}DEh6MC~X7`4n{Md&O%r?Ds1y(5t23!?7n4($dmYmHP$Z#>nW{ z*v^&(+QLYqh_rOLrVzQQh?A3(ad->)=XTE^MFP&iN+&>cK7-mkTQod47#0)5{U8Uh z4ZYJdkv|zrm2N&^iXqAqW!^cOz5l z+dVflG&GW?--FypDi#702r*MNU?VIKV}F8bN4GHP58509THg30A(OuznJxf*PKfip zor6P4MuymKE3uJE6njp1)3#RjEW1*n0<-ZcX=-U{2~>s0#(o1= z>2CkWnqiO*0|ElftI%vZ3wZm;;OWVdk`kYRC-Q9{?G4PUBH$C(u020pf^KFpv~N%u zaUW?A<_ux5)V{ksX#uExvb#h+Irnk(z+`7ZDAc(?5cC*rOrR!`O6m^7nuvASx;RDY zOFt?>IJAVk3Z+^^`)Pc9{JZNPA3+ZQPzP_c>le!$YQ$;q76wfP-Zan`HHA2$Pn4~X zzjf#;d-wI`p18W7)M9{CJw15fbu|Wq zG6!h8fGDwZ@uCALi6{XEgqm&n<&o+WN6>|#Pr&e|3*Uffs@Z+6se#>|07k0%WMZnI z7vfVErDG)1{jY&hA3S|$wt%ovKswL~2~SK^)J5b^US(EQQ^QzQJr-2h;P31#S#oFUclq{2xi>V0=<=p%!4gKf_ex7k zsokA_;(X`*Gf)GWmTgL=Itvz72SURkBTE!?eU1hZXsRmTTd4s!9tsn5m6!GcbS2d} zEKjzB{nj{D$2`gx9z)=96JcqP=e`0!rjw@(58D7Lt_x&W`RYvAFhSN^>pffSQxzLzrQ4vIS`ZkeS@)QK7UJQzpq@@8+fUFMQ4|kOa zvr_V&6`;+s)2M*bzR9+lRJ=3F6Fq+X2qd>lqn~vq0U5ghQir{2051aYIRIsW^UDp;cFo|#CEW2{$+bl{g&`2M&TtJ_sroJ9RCBBuE z1m^equMwci=X-;kmEXjIfpQ4GXOoz`~$Wpdhl|1)LigTyb$RaEKJJZ$&UW3K3C~ z6|nvz@konY9B^!_>Zhz-pLHNh?kWVv4@aeGYHBLL?|eEQ!f7tf zJYR|m6OobOYj+vxp#m7j1af}Jyd1BH%&X{%*~s^C>MaP6qFGt9twV|Que>zS@cr; z&suhVu{@Dk_&HM+KRwV`y&_J^fo~S9Mp@%6iY!r%zRn_utLr z^X|jIYakJOYCK23cI@a;lrW2fyW1xE%k>iyo!>Y%Mfnfi(W^v$xMZ-UrILkmE4 zpe+q9UhD#14^|usI^bm9oPuZMkb)RMB`GMZKC@`5xgQDK6Sj^vR#^|Lf`7%F_Bswe z5h=e7MLmOmJNe$3!I2U7jj00nvD7FYO$78O6dt*P>V&GPbg0BY)6~_q?f78Vb$6!h z@d5b;RmS!RG5x~f7xvNe&OOzX0abK40?^JZ=)Q9R+K$Wb+;;;$m@XRO<2-JnsjePG z(j1{iBB^&0etABG#hTa++`{DU)W<8=uC3?eAoA_Z&mZ~=q_KLCEy0fmABrSMMPRbQ-qVx~ zr!?Kf>6UV+LyQ{Nu5lWexH3QmL7o!>?$iiJ&Hl^^hy?x=9v|1YYQVkdU|vq#blK&zeqMwg$~)^gctfSr&h-KRdbHUrsI;I;Yr<|?Cm0xG1!X(q1K*%odscS3cTO~aw3 zOiOgH<>u;~-}XYH2rnB5n_qwqu?+KqZ=)MU;d2yaQ5a-yzb;)^%_j-W&6_v7T&oX) zhz4-Ez1Z{Zr?`qwum~3&X&Rzc#088BzQc56C%4#r@cS!jc}<(@>oYyxtaQ7R-1s z6C(tdvX&xdfjem~E3*~kbFKC8ct9$+zQC&5 z0M#;sgk?}DpfBGDehUrrmtb*#&QVLC{x(akX+*_oLj;2a2u^hA`t{nD41J%4dU>>o znVf9#_26n2nO5tN?oBXcw7N+)&ldE<=759fC>Uv+KU7gv1aSbhyJlYY1@7Ftn=&(s5g2KwWmu zNuwPVkPckGNfb!gHt+(n010H75%u1&a3Nvg_+kz7?=R1r_zXq*O&IrD!n#rYdvj0( zx3eo6(knVA(Q0~ELfWP-2|@`#F`RDcC)Ary9RMB#BOEX;!x;kVK;_M-8&$34+n%@W z?b|_!L^WZ^lD|zPyE@@Qv+*%X-tn28()AY|H}Xg1>8JbmgE@Bl8*htbX<2S`&~_Ki zd2d#p0D7A1=BjH&Sc59q5X~z)V4bTl-a*a0QNnU&eJ?mDud;81+4g)CIs?Gb3*qGr z#f=r-gK`6s$e#`;Ou0OChGqx~7<+z-`=eljhldALXS5NUlq`@MPEK;WcPiYO+z)^`J9z3GfOkmvk9ifzfHI_b^8ZtPTALqI+MKOGtsaz*cECV$ zf+;j8wt!OhKMIP6@hi0Pl6&*pYK?BdZ`NFx6Y>TnOa5zZ?IB1AK#I^X6j7}huOk3* zRhs{L82vHj2#0|yRFng1FL?TNEL0Qt;EwyH!spK#fd7Hdf3$C%ui)PfgZYul)|=n3 zN&Okc#V^24AfDX6e;c8F_Yd}W=?VbQA4}!Tik5g!+hjb762&AC>XzBos83qE-;sx~q77c-7 zdb}xVd}=BcCQ!iV!T)bTNYD0Hprp0C#A^YvD>|9;E6Z$#IRFY%LltOhkdBVP=|FRo zBjgKUN@2)8Y{6xAmjd7XGxZ0t^PlZc6mZ{NaYFZFg;T2Frs;1zOOuei{Q+Mj)9ibTrh_JJe0|KDz211G ze8O!EvKom_(F(;aM<*=qB=jFNFD}B9BeLZ%(mE@Idn)~sTU}j3_zSoA?~4C3!pkEn zjl+{U8uH^7sz6Y|u9w)6Ls^H!Vqu}^G{;q?S$6O^tcKASc2n>7{SF6?-W&Nmb2`ILW1 z>YVzzp>IDq`(mCy$ZH*h) z<2#~{@Qju4_gz@;;RVlTR9Bv*A?3JM_8o~M$Q?{I>AY&?lptQO+@4|EtncB%TG%YZ z{;!PdPPgqqmQE?TnNuq2V?*IO&-ZAQhp?+Huc`S52VF0WT6|`fixTv zEIAk)v|*7Xu_&=OA}ESZ2!Dznr{XhASUp2 z&o^C7@}iTB7pGfriHD)#hI7aKL)ZO7nLR3cRk?`lrEHT-HoqT{V3eQiyUw|`*1El| z-j7))$sxwXlYU+>(3PI|h%)4#xc`AOCm)sHp|ytpc#!j7Frr_v9Zuoe*bGRbSa=f& zRP{c)zRDVouztBHAyzBa#qQA+`M3e&Esj*g&-|c&2;)8^(un}^y(m5_(bpO{S6>mh z8aRUqp!0}}W7YID3ln0cdqTxwdsAlSSCe+IyX|l_m-Sy^w8rRnOTv71n=`Q-e8Pu? zR|bz^9@Y{$@ZDkMFMRXvc~1Yg>&hGv7-0sflq-FbL>yBs`X>`R9k~4M*J#I@_)KDEByv0%^5U|-Cts+^1D>^}424e(;B90te4hkeT6lZ4;N zIeCNHO?~p`&p6f&&K_HLn%3HZ-^sJ6-6z*~%{+G_UdV2@16=^KID_1gj=u09Dx={J zKSo1KZrBY~jb>!7n0@jYAuL5O-{KANE$J%N({?prL@+2=3&FK6#>@BQUxb5ng`0Vx3h zfH2nJ2MYkWV37;(^TLnS;4UWo;C4S_at46n2ZF30dEj@1zlHu;L_hTy4u0A1Y+zvm zK&U(b_`3jb#^a{|2s{bEj1vGFPXLg(nO=243+~{(YNY=IU~~SmYH|s1%Z{4{HvRwz zigNxDAT@n20Fr#zAI?|>56lloJnRk}W-s@mt82Mphw}D^MBRCKZNDGl%2k07Y=Zih zl#*D}jfR&wr_yGtBJESBpS#+(bgOEhtxY}d-x{Z}@?O6E@wJ=r2c>A^6}8_k@cI~; z?{blNIJ~ksR$%v#U$8ptRvY@k`{s>(!`h>ziz}|WZCjcC}`jzXM%&ETD2!8uS`vL!hmp6j31>)cdHmLpD9jfq_qZBr2VuW&>qa z>PM_RN`YAcYInXqWRvNFh2BHQOz1y>yq%VWW>dej&=}#AW+r0lP7rj=wB#^#QR7z( zSlwL;rKi$pC{fv$U^R<^s#K?HW94>%q#zfl65Codg=~*iT53X34Cr^kl+7dxtxi=) z&;-nf(O7RYx<8^lBIXd|EVM!}_KQQb-_uT_^`KTPBP(u$KIA5fVFBR`l^c|JFk&_- zv>cQ=%H}xG$W@?5W66+9fipk;$of@imcTkpJ&#y=r;|+kg6fn}0bLLE33kae3Qz5N z0+z0bZ4HP6=5^wzO8JOSTzbMaZ`s?}6DEw+54xvMfIMIQ=Pb4LhD6foKF?&ig%)sH zhl|UWd1EL0Kj*z zO9($eb$Xx;0=;iSXzWg7h7^~lrxGwVW<-k+*MpI@w{qlDAi5=&Vvhw#A6-sn?|GmE z=Gxz36O8D$Al=@xfMpA@4U{{a1%alj78R>NIq~VKhcG?|HEQa25sH+v1XGET*li^$ zK7qf_!US>hdulg!oAiD+SpVRQT{kQ_h4obBuKkceVJg!55SV0{3>SmOjeh_COK@#7-+v?3W%bXo9J~&&PqRNttANC$NuQ*zc5+|gGlE7)yxaVf|TSZ{ivxIVnjO+Vh6Z5R-dS=6THS3@$OY7*H@^1A9&H)H}}PBQ&SUpQj0pK zfa?Rm-2Re0`Uo|3SR1*-6RQiX3YBC%8Qw%hurd|3g2caAvx2DA{=A=LIGr_ES6hgJIAwY!RhWekR_i{RSHh`hjS#>8N^9dQ#KM)nA%s|Ls0~7X~LlEb-$(n zUbLA9cntKEMGjUVUYT{=Py^qyUWllVUGmtSSQX z1T8Cs@y}Y2VCL74SjIWLFjj3cN>^$x$djGNG9?HyZt!-hE*Se=R-y)%0(_M+`3Y^8 zKP>neC{gvB5n!fK|30=M1qr&6eCtuqS~x9JSE0`nFgCjeD^d4z1}o=1qz$9u-4Gzq z+336Rqg-OmTwvyW{=?@kJAt{Q9oJsK4MRK()Cd6h0l>f#93OVU7!OPTy6Z0v-w!MN z`yziz`|rc&QqCX9N)?hib=W=K_k4o(dY{bAT7;ysi-DB1bm{fZoC1d$ji$%Pn^=sM z(yR3Xb-&og$!e;tOtduZ)|Ywo=zzXkEgo^*)3Yf?Seicbk~Hy1Uye$OTzmO;t;aUz zT(?61jfjXew7=W*fG6pA;pO6Ax<*DE22xDY)RM%HU$N5IoGUdFl{Y@AJLv6mPsYz+ z+LFk$nkH{99QEoe`Z4#tZCF^?^mwx4iLI~ibx*XUsy4B1bibCoQ@H5enPYNvICQo! zJJ}&LzpJO`(tvDXVPUquT*&j)aYbDJF|Uk~iIz0$(4}{LL6u93hq)cX=F2){gN81o zqWyDh2DtGpJxTKqaI7(JMHbFytRX(zlT_2AG zg1)S4jvB(LbbjI1J06L|Ox!lZBIvr8*YRQB_p)xDo3-;fY2HT)9Vz6&gvw;c$jX4_ zdWp0`6VP`fV>ri*=x~J`9+F?}HS)G{wUoHk9Otz-=>6t%Oh=AMTGiH*u<7h%L;uNE zRp&HN)PJi?HgxJ~_U0mm(q(z&$`wOv`@kfd!E$d2$8g{2!itfZVrOqEbTC^_M7yiN zi>uacu-oyfeMBqHIx}nmU0UunoEiM(XF2VWfq?94Ys=&Tcom>o`vZ z&aHNuwS9PW;AjacCWiOj&j2qgsqSqjZ<>C&cuPzm+Bj1S9mOZ2#?lDv`|(~jj=e25 zINDe2yz*HD*Y{~RC@M6=F+0T^<2D9;DDc`1OS4^P_+-1rNb3DPVMJ#H7byQIjgu6X zR7Qm@)>M4%&Hr3?&pMo_FXMan-2vcad*FOr-dCNp)Kukb?fdIUmNmiT#KKIdxjh5Qm$j-jLGQIrE1>v#c0?|TpTBix} zCUhr@6v~_X^(XfEczJnUzkZ$i;^NDaAM1GaT-$Jb@(Pm33{{_zx6SEj6+W4pnDcRa zivyPx_XyZm4PSV6-j(*@k=T}vn%6+0ghTLmum-t4jex1A_uM|!-WD64O!Fb9^g4tU z8dY34lGM@D^SGy{hXZyYYu(nR0n;5reoe>athmAOV%6-crlCrImC)5|b$Fd?Yct)I zE3KOTFm&wY<-)&W%ve)WEzPu5VH|7vY4-sk?iaOHfwS~n-7STUkuaagrKo*-`ML0| zgE;np; z!k1$mn?-P6>zY$lk8Uy7O26uCbnCWKN%KN>(&8GK&4Y9|Tj3 zPE?|j?fkGib-p^);iZbg* z*+uP}icoQD{?PAE^xDOR5B2YNO?HJXNR>a&vMxrrsd6&Ylhf`ua6Q+xH66{12=s;l zGDS1!)fs*;Q*Yy*k+i+FDb3wg2|p&^uC%J~BQV-ePWxV!H_N=S3j?{(Y19-Y=NNdn z$MD@9qRM_WgD3!@^zQ9!d40W8=qxawJs&SM9>K1?)Y8&|UaCb3bJ`l>B*z#yrP8Dt^P1rW^-*MCx{a7c2=t~gc5(QniZzK7nxQa4za^&^kGgiT9 zss~sOuy5kQzY~s_ui?bNVqkFaC39OsAbO7gkH1Pq<5u*gRdp#Y{E-djEXn=jFXt|9 zsTilJ9UpuDfHbAk`^rA8K2{<(J3HHBvV1gh)3v9-Mr_#K*4B0b4(PAT%P*}d1;JOG zU0K<$xQiQfd&2QNMcv2YB3md{{P>Gt@cQ*@+GAU9s(sM##WMfuFZYigkptNl3c3uc zS;@`MzrN6!xI}U|4yKe^6%qg56aNDutu8;?ZN38zN3BWL4mRDob?r~}j*{EpFYmrS z*Po&h=+WKk#rnD|HtPP={KFxg<)4j-IyF_eGwMt+6OnCAtc7w6v)F|SH9t{$_GR;U zlHqk9AJR;}SZw_sf#~~-6*V>5*%oWtD#!N-D2T^Sl=DeRNgXL6ay*9vUxFQyU@GNY zmIbBR!^G!U<^*rxlFx7ae<^$14_m_@Vdj6nC_MQWt0R)IEv>KL`esXyAB*dO@>1Wi z^WR>nrW|D zWk#&qaJ+?+X)fZ}VNtMYM^!J92WJ8PMt{jcLGfcJf6};U@TKu&;MDf!V%uDQsW8_} zU!90<`h$o$XWq|3V2xHiy5$X9obxX8+#Zr6anQTE1@@;kx9N^-o43KmM)48bE(R=TkC`9 z?XP2c z6DmU7U_2H%U$ZfSW-Zmh0qfd+^C&f+k#t8kv$v8t^RhH_%>#J`JS?!URtp*lapx7< z`w&jO(V{^0%~$qnu5L@nWQO~)K3Ix0(_y$kw5+I|jj&Bx9^NCw^J3^I=`F-qnVhs8 zWnO|aR^hvacKknWaQ5pDCzNh5oP7^oe7mZ}h{dpxg?lO>ptUjSsQ>1Cg*%+dODbLc zAvG;c#iPsM6Q&1T$FZ_nUo|(xOAB+u)*XtD|dnSj6C;kI1{wcfr7hQjN zEr`fU@2m{D56^?9)8-??5z3=#hBRdAjgG_^x_5ygyvAN5GL6Wet z>JXS*==+@N&_`9*N`d)21g1Om2$d$g_27#sdHRw+%vh}7_GGlS0wFVcFr+lW2QIq1 zLeHRr{$Um12@SbK0`nwqW>!7UnsXo`$_R>6)dzV}it=a;>Wm2U&X*J3PJz&D%Wm+Z zaZS^frRmSRADaqkJ#k45;ldC%u0dnajiG)CF->1RX@Z@5CNLL!HQ4=Sv|j}*GH}?| zQ`Rt#BaUJiyOWDxVkguDDsvMA5gh{wWmT3^x5i=|u^mrb$|aE)qJJPn3|fyBtPUC5 zBCs^5)CnOF(NKfkHiKHUec^QnG+MdSA(x^MB!*<0MDtXjY*0F-)}8c^5^i9X-!~(& zdhH+45{9>OVzh_)55NSUa`zD2IFwE+(PlKnoS0k9M_EELH@1PfZ!Cf3xxOSDYQF7)uNx?b7`K9k%=Oeq^6x=&N%Tn(Bp9Bp zN*twEWRrMmAES&2LRjVDF*q}xV4_A1OUTvUMNiWfojekw)H`q;SBYG zc4Sb1UZaG7)Lt__fU&mgg_+5j1@h+@xvIKCta#uMS%R0Vc42cPk+|YGDylxx9u*XB z1Z`?w{nYoy6b6R#MvXpeo42Z_d2mTP@f%u+v@(=_gBTLw2BM#fI)6}dahWe`=WrU$6T2`KpP|0`MTD$O zy@N{AXyrYH76*AqPl8oAe0C2_=rU39)2VmwR0LPvHu;*r4)fs*GfbX)I$)AAD`6bunlHe!XZkpQq0w5&H z`9pw|G;t^qVE;5aAJjiT6q=CeUxTKtsps7xaJ?|oyW9Nh-o49;F`rDus=nMSGrc2w z*28XksdMpsQh_FYzkT89xRmj;_J_Q4g0_7+|L6?Qng0}Doe)9nRU;(x{OR6HLB|}k zzN22c)>2#Tblrg6`D!hreeKrp;?|ry+S1X(ESy+FntSY8f!dUt&UDR1)sXxwOS(2P z3xq%y`BAnUwI5YLs!qy2NG-)mSuTYUb4kqLt@f@c4`k&5>~^fCA~g%Udb*pO0Bws{?S5Ipu)A&K0OO*8}X3@%ud~fZQx%I@{Cx?)l2S$n=2e(Kco9f#i_ZExJ|J zrvUPH4t0cd^~cNGrgV$)U4IVJ61&@v^YtS^-kH$sl`8Gl>8N;~V^v@-X)G68OLCRC zj>J41kf-LMq_ML{c$|tGxzsfa6$~G|9tJcdpPa6Gq4YL=8*|=^KQ;nc{B|y&m+VLq zHd{JLj4j9lOdDb*s_%V2SKY}KizRAV#vb+DvqG2()TI3SC8Em9gTRxhLJa$ZYCUU) z@s-;}FvPZ*Et$^>fE$|1*kTkCWqWQL@5R`a#SMzvJRm!6H}B9YO#Y9PRU0q4eS?M` zN@umP9AdJseBUgO+j3)U`(z>DOLbPHJ8v{bBC$U9#QB!Jpt)@p8`mbEf&6ksONxPxMuUXH z`EK_W`M%D0Adgzl=;OlJN<8Ym&uw7$Ko$%U(=I&+xSeemGKc03xo*1I6|W&d0ww)h zh+$p0#AX)q*fyFmcEPgbT`(7>AxO2b0_ntxK+}w*5ot5Qr12B(b$N#;d9Usyn2_eYBLMETl$lT6#D2t4BL+97VE`Zg z+}j5s?(GI}yR@H7OKb>Syhw~~6$1H{8w!=e=XXBI1O2lcdE(rCD?z%5a1HyA}Czyird-TnDT!2@NTvlLj008 zz;&JU%mas?2!ZqE6!)!A3Lm2fWzfP0^1JP^A+>7)bvs6pz^b@uqlF!cIQsE4Aj|Jx z8s<{}qz~_eL%b13J2`jzQ_3=UE@Y0SSF;OmBM39ERjEmi#5onH6E{C>NnkQ6^a(HM zOfaZI^|kH?)|7 z?|+>V$EwwfMMNG)@)MF`wkZkw%j6S#28yz|Kr3P)S3+)oS3T!3|D~6jT zysIBSfBt;%R+Hp44-d`#IB=0ZSN6?9cct^3FYP_Qdv1C_bDZA7liKi2x7or9?KHtJ zgM(>3PN6Er)k~+6RP2)`Gx3HMgFa?n@8-2X-2Nlq?5sMU($KZs^rSP-PF~3jU&T@) zwo1CJ?f0JAWn1Cp=oB)Y!(4C2w}r0_C7Y>Memuj419?`mCm($JcvsYyF&gXM;}~e} z=$M|>dFROWZr9QJ=*oqv*`?CVtriRhLEfDfzS_4}$ID^0|9Y|C{LHzRcGikwC9YKB zVQuY+G~YJrkB0jqyka1~wQ@2e^|ecrTfD(4%_|fCt5L#xEFRBvX*)KnER7^^VU92w zb!s0MhtFT{^Y3vKiA0uNZn!t-^I3(XfNvcgmu1KRca0Azl}2wzqeVnSwy%`-C8;{5 z2&ul6+2b7c`Vth9!x&e?KNYn&dcYub^{{aN+)&_1>wLvXhNz{!#vz-c!#jfFH&AGG zzUGFy+uT|=`pfo*&b_W}HrZs?b@jNn8?3cNZ4+Hu9q@K%G1p_b;sDOeLT|n-jJ-M1 z4wZjZTx_Vn{JL+KmCZP`jg~MDDDFvi46v2ANSks*%awZ#RSgDDrtdWfDHG*61H{0w z0dM@~zHPxB5$;WMgh~f%HrLW!E@^jlPWTR&z?@XlS@PnDa>T z+QML^?%b;n!hc!N>xI5ag`kh-P+O)GN=8vHda0-96YD#H--B^o_Jd& zDCyOlaB#22wbi!V!-}K8 zb^7FFhnZTrxSygl;!9T1+aLE%ryc#e4WAI5>>Q!1knVRDBI*rMpJj>P__p7_^Gu&_ zyFqEgZhOyV_mQBV50x*xlCg2Pb)u+pV&8q^SjApX$=BiZ)Dh)sdYc}vBEMSA^4Za; zXYs1kTGr5FM8zw==??9hqQH^x3U%+{>iRo7Wtl^-5<|3j?58WTjAM6Ozr1k3KW~P% zI9wAt3e~Py27fk>)UHpYM*FtuHjNoGvqBZl$kG@ zY0}61uI5JJiJq0?NxECh-&B4-otd32_3SHwFxw901+8@1&~wznU!X?uL;gMEveN6O zxm#YvHLG?T%hN88Z}AY8!q(K7)5?deUw(pt#d?`0tsis~8a~BBgpHaWdKYM+J1!>l zkPEZEzMPA`A>cQo$G(g|Y3Tat(dnm}auBhH?vMC>dURm71TSI9r`y)uzt{FFt$h@~ zL{*sVw)a*E>1C}}Q0A8dS&QQo4KbMcpJ=!U-`-vU<3ZR;fwM{o1G(?8VvjREJfQDN za>+)xS@`B<4`Uly@&&j`Qsnkyd7jSBzP`SknCPAvu5Fg2Og&UwO@yx)ME^y>Mx>zUEo8(w({@{yAB#{a|hyG1a@aGOzds?fd&m7^^<3GD+Q{ zH?qVBcTueq=Ak<(f7YWEI#iaZUujMt4sCQ^o_x{?A znSy8ymqxKIe_<*8x38e>N@`Lsxwt&rYM2P9d$ZKHKw+~=He@T^T2tyD>I`3)`j#8N z@}h2j(6?RX@R1|i*dh#wKX>WZ2LT)nJ>!fvUd9GqjzS|AnninWZLX;ehA!4f2(y32 z=@m`2>s<@Z_55CUyAnEk>44WetBNE4Vv!Rm5iDs&=Zz_U>`1bGx-|6l`dnoI&Gh+Lwv)2L) zKn^$G^X^7Mtlff1G;oGqY*(&?X8np+;Yr=k(i!qQcOshoGajAM9DC%LlZ~T9IFmLGl_4T=_*i>5d`Bop=%528%#_6Ur{H%bBlF22(6g>N0aI3kw`O~AfF7K0& z;7Aj!i_zbhdba!aL+H{!rP)sm)&@&2JJCCG%(MdLdRv}oeBj8cJ+>(Ug!ZVG33x82 zvnlLNip_aOJdErR%Wnu!2Ngw*?*eA%n_&rSLToe)IlI={oEgsm5Wp)ArRvCnd=+M{ zWv0`uQFgRi*a6>(me+)%T;NMLZRC1!jS}3YZONaU@tt;z%$xPw>ZN%Bh7rjHWSB+> zu!DtDuH^;ivmFEa4R(FZ@?jyriD> zKdZn2V2H~!42N;rZxksAaAu?UwV^^Kj zk9zjopK+or$kOJwvAMS&#W0NT>hc4{R2pTr=$(=(6TLNU=?V+0|YxEc6-=_*8f$?E9@xRcjEN#eH^L^T+Dc?j zp*rw`saLd2Eq9BCXtqYveo}5mNHfN+45^^4Ie&yxN8rqNhPohaodFY>CRr#aqJjHp~d=)qcG%&di>a@$B=H8 za8Q5~8l~JEJ(;@GWTkpMgEo2sJ!;ALDFA`{xzi=yz?w*thCEf3 z!t@!9mj#%W)Faoeq30VT4H7;e(fS28#i}6;=dfQ5Fo#v7bD03b43Zra6O)2u|N7a) z(I8oS5E9c}yI4ZIl@)Cn>_s-Km;*EqS!O)sbN2+UuVuIEI8poZ#U z4nm7+Emt95;#%W6=JNW#3iEcmh9nhy1Mc9FFcFC?19Hi)KShb#!!`U8b=i zy;o(v-wE;}AT!9N?SaU~6h^Z@J(i88a8TvZPCq^+Fs&Lp5qJk$Q8l;G8zKONkDCUr*Fai@}gEK5?60ildJ)1vD1s)j2UZgMaEV ze-EhM!Af8H>-g}OuA?KR?v{GIVW0;=P!eFf#OXf&yB_EbyZLyX8FEW;Uwa?YgW{Uhtrkoo<-PpYSgEix_9TmQt9 zxCK~g5aEW>{5r7zXE0LQa8L#VP1OK#Yqr`Z4e_ig!j_FYTK~_-Fx<*=Z04cD44;7b zmti7!;oe{gdOrHyVy!(V62OqCbNH}0k0M+wKU{>;5G4A)XMmO>Wiqpk;BWyvoF>0R zGH~#~f907x+;?RM3@7D3U{%3UKPcbLGLiI8Fv29xc<|sVor1>`iTVqXNW&)(sNDp( zjOMRUr&k3qsSE)CYX|^k{;LbbB7nWq|EG%#+|+wQ!>VRMnhHp zT0F2Syu!c#CgDIQ8Pd_cT#BHxWNUSxeTN5bq&8dxAqG79V0iWxI5GiarQZTS98d+* zX3q|f7KpUp103N<`Uh#&7a5N~A&d2$%8K0wb@?bQ=*Qg{oUCfiatDXi@*xBPic{CX zIDd7ART0dZ>z%oV92M5FpQ>Lv9wQ>*w!>=9hO%K5vbJ#J_vh0C72ZWvGx^KY-bZKEhGr4DId1$DO(z@XUB8r zf81r`@OUw7Qm^mweHCGDG0^+gV6|6PXYi(Ro8hMChddlC6`tw^ddZuoprCCD<`s}` zO+be68JaDLWNFxkQTpfO7-kLJ`ym6$+ zzCyJf%?{hGeBn`Fsrw|PVW-cXBQ+cng-O8kpgDJEW2NmJj=VR`$A?5OINz>ZHA>Sc?p6&T>^PjNy_ye&x)Y{K18ST z7RaVB1P4qthd6T_6!y8?ChKgX}G2{qkd1x zc#{3TAiewj+5?_0JC1T;ZrGQ)Jv>nMt!8aF6^30%OB3dF?dZ^ertuV7`9_Dn=hSe`gio_;S|UA zP#7m-+iyan+rII9%6s)0)ZacXXnn?y`!Qcma*9)!XEOa5^fzT?u5D?$yU7o&iyW>A zC<5B|NaVib5(bcIr>_pqZ^alD?$LqH5X?cvaKz?~$h=p9MEYcV1-Mb>O8t*91YFzp zl2hH~*C)ewx)0o_CH)22-LIh=6r<&4`)Dq zw2(-a2&b815xOw?A=aLteF3g{OOh9`++b}J^Mwz}bc7|{;Ar~*1h(*A&5vAV@6TBp zT;VKN2Ua*WbkKiw@a?U=+MX9;4JXHwoikt+EfFE1PpjjOfgx&f1^TG?af#*m0UaAh ze=FEh89dM)2ur_rfyNRwuw<{C$uAIw`Fr?t^p$>&2R?<-yiIp9jcCvW-B~MeVeo-8 z+7K4LF~fYE$eR(qY8y#bdMhDw0s=o%8JdODe|ey-BqnHEcJ&OWOox2tJj`@)#MSL{AQ?~ zL?N~6FEX&D=F+B5KOJcHEZ@Pm^$15B(%CsV4T*>s``Y1yHoZmtNN7XzTaA0&%Qg>r ze(8g?QBm!N2i#qk!uo9uoabWgrq`RHfB|6%?Uobnc^8ld%ecme67DJvZgK@=XA(0BzShY z{QA+*2kX@YoBasFD&xl<$`cc*;EytS0@u4={>DFtE;QVPwTU|{*Z7QV4~Yp@E;S#5 ztrljZdR|B^FD4e^#TH^p>+;7tojpAg0#{zVeysu5aRh2r@i}DbFgONKVS1S=xSfy= zNvTriMrZC4RMlR-BkxUsUmhotnOiC9sns$r%b$Mo==pQ`4`esSZjgSt@xk78Mibg= zhhLSAbNCTa`O}{uVe=A)@Wm{W!85Y zG0EZ=FAkFA&6`(xoV~>r!I!Ubx)VGMcD!;y`c5y#-F| z4=SIdiE8Lle8*eqJHeHoJrtJz$p_+(A)?yV74vr;vK|{4VFkdgxro2YC`O!3;-n%D zzH35OG==+L=H$rHCt5$-x~C_?PMHMsv-5u$Me>dRC8TOF2arG?3D z3>kM@zEweqM^}C|EcI<+jsM&-b79B;R%)1oL9^Wds)-Nj73V!D11{lTUH&WK-(3FJ zDKS^X2!Cn7BanlB*pADbLlL_wlxjM=depnh& zT(=SdX)J;ch2F$3;bXV<{QjJGQ3W7>-|-W(7Yx`RoJq^-dqAF@x7_sluq?p(R8#_t z)n;fnr2-sI;T$G4itHl1YvBd?{UfEd3z@8V1>&3wJcshnI9;+B69?1QjR|gGH7%{l z$`qUpvUl5P>VkfD>T_s_7aIyxZlLcE;rMPs%}@7gWIDt#;qwbg zaHtEq^NOg{)I5sRDhib9$B=?-a|14#4#jLq*O!-2;%`iB_ijc8^gZC;vL*Jk?gq^r zu(=vS>hx~pRx|H7LKV>BBb4|$5ofhmVrRAK?<-FGm=T|~!n4iB8(2fqSo+!h2vcIx ziV4?Ag}%mW1o>M{%2-QT#2TK|RWW+k$+RQQHQ^~YCRa~^x{|sXKQq8^r4z$YR2g4> zf`#u1V(GvVa(3WXHq@3nwFsX7N-iv*Moc9rSv~NmHpH%-Up%Q>j~|{b{9H$|)u?~H zF~o%HIoJ7OhfSBLDEkXYpjg?3txX(zzN&8;zSHT`hie80X`X0(FiDlWnecy|kFM*=jR? zY{8hFf_CvGDw}Dl7l{i133`~F+LAB!MP^=YWJFdhN%?e#+JYni5|_Ujbw4y36uLWm;@!M@?!dK*)?aUYz_XYWS1=ROQv zDS+_Rp?8FQB}Ppz+A}9qI6N}|4y~6ndQmQm1TB24SPCZ-Q3GzQ>c7#|_ zMiM>?{+6qr~~86^)t;uSO59O#kY_x0P@7tP?h>r zwR3;XGHgN-jMQPQ#VJhABqly#hLWKpd-e){=4I_SC)d0<(mQg*~ zD`U0ya5hY#i37OcP-EqgXev&*h$4 zQ7NX5i7G8*lq~TuYHvpO=5~OwuSx8_-gQ{M@{nXuVFWKZJO-uf8m@8lFf{{P)tiav zCHP}m#4*;25gTKt=I9IyV$WN%q`CC&P2^mXD+RShnoH>RmiX`YVdx`V7%Ro>mHg1_ yBxvovb#rWn`BD&ZlBAzL);>K}K=;pPBda`P*;+gqEie-R>wf@Fw@L2+ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d92601c3a14dc63446b255002c5343023677ed7c GIT binary patch literal 8003 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..340be0f1b81fedbee95980c816a1ddf3697f33ca GIT binary patch literal 10903 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 S^>qbqjqqyc&*iCF-~K<$#nop3 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f8b536d5ccabc9a1d19bf36d6c122fe60dc47847 GIT binary patch literal 7162 zcmb7J2UOEZx1Uf|P+Uc6Vo-yCRD%RV4PpyOQ2|A|M1mlogd)gR51kUy+e5a?6>cE-Z@{{O-@c`?#$eoJ9FpW-@S9s+Va%aEwWnx z09&!AkDmiT04fTAO(M|W+|AD`(4P?A#KHuC*KuNNZo*I}(v^@a; zv7P)M45ZMc0gyY3J#J!ilRe%Ona~zkJ~{oRJ_hy^@~5+3|DnL!zlJ>+R6iwpo6%lc zlB3bSOLc$UffV~1!W`{P!JvKa+(9uFv)6B0x=K>=>?p=sKeeN@Op?<7$b$ThkJCqgNyIL2PzWRQV7v_D=X$v+M>yIPt!^T=5k1+RP z7pz)Tv2mGQZ9+t|rsNDGW+H-0%CRUwGD!%QQivjR0HLYVlXu8w#bn|$A`2n&3l2wm zM*6%`3^3ZVq%}om8A6O?K*r^P@3JQmXjP^R_N3ylPoxNaD+)m&lfT>_(2T`7o-NaNVUnkqD~Go1*jj!B?= zfMMzHM@rvJZ0R&+4w1t&m^T&NkYxBMfQ4fWM3NFEnWMET37kF^AdD@M zhD=#l6Vi~pcAlh4+T#lspo=-A;4YE$NoA@aXuJh+K@r_)B#05Fq~pv;>05}YsVN+9 ztkD%XjDEW(o%1%~gMewYC2u(p%TugsJpg~}0<6ErWG(3|_^RX`?YL?#5$pjTnjv+n zBv$5&tbn!Rt;}~`<4mlx)wE!g?N0C|W6Al`<^@6U;`1*mdESMFsZ)uVQ=*meh(7a~ zeC)z}x1jalf&~)Ae4>)K?^WIoLDzu$@UVBEX14-;nO``+OZM%!cnJ%eD#^USQ@Rxk zquWQ3X=$8%EYD^$Y>I`gpk^?Jc7q`A4TSr-HOI=yQZF!f28m``U`sL>n5|H3l=1q2=Y9vq&7aD^O)@+VKJSS4gxygb9cHO7UZwosI zCBX)5AUhJ!*J)`3UBHKT-&IARpP&-K>btNGQBxCfC)n>UT08J%Rb8{^hyuNni;|j| z#accWQSa1YT8OGQ>*|wYKpv9nt|En8UPiQb5a2vefOg82`b?tu@A=G2Mi z&Wa!Fv zR|aAh(!ey(bVbc#dgg<7P+*i{x$yOpC}R+SzKfS|>FVo|A+D3x4p%+c3V>5x0YXX9 zzKg95(u1J|XpFwfbPsvS0&8gI4X!9~YA0Z0{TgJ5=6lv)^g8{#;pE-{u#`iAVNQ51 zB=>eZ042mSIRu8OI(nE{7^){Yg(V0O@6Rw3X|S97m|BHUW%@EnVthiplCq5ltZ&5V z$bSo#0a7VH0h>AJE3iOK;V8r_i zy{{u`@?m*aEnU|D{j$&L7JQ91$kJO9#8i#;1k^`r0?WfY!SgLyW76P2J#-a*`-i-s zYeopb*h*6*jfF0rF3@>jaYPGPqh(2-+YX|uKfu9@lRhMOFY=zNyMeWBY>ox;_dS6A zLOk2TKL-sBM88EbtqFkm0$GG&?&AVrI~dPKtWpJm&Es`%@*GkWJU>+KMY?9*(x3+D zUf1%mH%`!H3!xFs7s(mT5VO*Y0@6STGVqX@h5@h%01Q-uyu)_L!b7G1-1IlQD?kkY zImv(Lu7}Y*pd9(^R9xft=4aZtPx0c?S_j^8Vhb=@4kT<6kwCCQ2Vn_-!iyKLcpvBIN-0#yD`PY$aBtsqFa7F+v#4!g0f zGu9_W*L>!ES<3RVw_OG*8X}4}s>q~7o>>g2*R^fO2Rhg84AIuSV%na8U*SZ_BtJtQ z5+u;%V^UqR+uQkD5b`xz3K=+JBdGFII!yVOuOIIY-BU{YWFjh$lODr`?B+DDowCEZ z6>gNr`=V8(hQ=II!wX#Wg-@GCq;F8sl*C6@Bi848(F)II9@A(yvazdS*8|VDd)Q^- zO!l5OO>yDP!~Mgmxuttjc8(j2;UIm5G~rBI_=5e%?O@Qow=4ubm~dYFcB%n6eXzN^ zpf0K>c7@yINV zyhH{rD7J1n+dO|q`HWvZe-_U4?moOlA~&@x(G8u2!waBr788w2YdWe9Vbzy!q?s|q zEjRXD#Sa(?iin@~@$vCsrTIwva2iHXBVEOQ6?4C8qL&KE^Y*DOgVlC@llk_PyR)ez zpPItuE4#U`A>)i0 z(F0o{kh0`GIlDh-G8+^Pgu^1ki64Jwtj}9rah&y}#LSOz6%RWhWVTI>Cd&}9X z%lKMbd*9%)LHARZwY5{D;bJ%K`fr;>_5r;b4wKyaKG-kHRcR6z0&NC$@3%K=*$rLO zmv$#8>}T%zcF0pN4F~|Hy zrMLH;1Ydo-q^cn6km`prWRoZn8EB0cb-S;dE_C@BN6{&fHa?6Qca@6U7aVQ}R+Rpl zr^anQD<;|eW~{g`qGY0U6uLSAIcJ}plo4pyI9t1KuX|RFbQ8B`3A633d$ZaFF-air zHP-Q3MlayG$*TuZZ6;_fpDyDVF1)Ku-jr~O3ja10RwUHb{I+k9zUQ$Kx+(TVVXWTB zb6Rk32w&uCsfwxSfKe`T%NQG5;+a3$rEchdt}Gw*_q@fHT!-p7xZidGy8E8cvDYz`zNsV@Ze#7EbI;;u$CP!mfZA1Bn4wPS zG^-31O(T1o7i?V}jDJ}FYiM9!1^Tmp#V?E3ry@>GVaZZ_6th&P_P6qsH*Mz$JSAi0oy@w=VC}IoKZ@TZKo6fEX{*}~ z)_it@w#?ArNh^T_YH6vS>2@$a^!-`fTLA@(%t(7KRgo=>S$z$*W0v&>{&7Iu1QbbR zC4;p`yZ;IZ`8%lM-@ut4EHDs6`K#y$8ud53|A1ITpe69vFcvh+k70kV?O)M|P``g8 z-j5~>)ah@C_haH8EPl}b8mGUHDixqbLZbvKH~pvmrOyUv-%fO3M1(ts4DN~o%B_s% zB1a3-Nq0aT8BqV%2$p`$e$(?^Cm%}x!Xzun2|2YqJ@LSQ{LPn%1yphGE$>*P70-`P z%~!a(42rPWZs|Kfx%_8AQSNOq4XsV384>F%|?@d4gWxT5x z&quEN0GkDYVsRL~{^+_UVE1HNP{m=DeI77tpY|Mk_f3F@5+i!ewmbOAKx`ZMY^ST` z_}ADuay^jG)|2A(dE(+NfSc6)9IS1v>NkD?d&!UVE*wk|GyCJIDk zLBMd!=PzF%N9!5MGTtcL2NLcLrG++b7sAxMTb+2HyJ?fP2p_1wpD3+^AY#qUvDY;@ z=&jZwyEUC@_JtmN_jj}q&q4_iEf+XsE7{wSJ1p+@H*e2%z;6esILO2NWg)Z3kQrPQ1mC_D)M?Dx0gV2oH(s^}Eoq+6 zLd(=H6)EfX-gJL^t@E-pP<(r(F0nhH)xu~=3Dma6Ma(^eVDQHw7%+QYQDFr6T|9JB z9?}BzFefMiDB0kxZWW*l$;!zQ`ob3nYgO})h{LRT)sN(oYW9ZKD*(19gu0jO@kabz z89ln#O_+sZi2aWqJ&J+7lFDC;oTc`0x(O^D2(8aM1~i{2fzIR{bY#+hg87N}nnN@~ zEabQHyw{edDJY*yD8Jro(jfE|1j5fykcxj|{duRrHe1Tk8<(9!$8|n}j_6&gcDR-d zYza5TTig^1BSI(3b>XWWHOSC~Ykr%-nCt4oc*qP>i2(iZ5X2GL4-TnymkT&-L2OJSp)s_KQjBimutoe@(}a1P`x8XwAN7kD(t3c_AAj51=2>j-}E=AB=@P`(jfErZALAsG^PT0~qrK5Cnu^6FTj)yT-m zxeFJP_8Q*G$jq$n@5qJj``hoR_8eC$=K|vfkv+ zf21l_*a(Mu7P&TNj^)QbiT4~CKazt0N+`}xib*E4O*d(S}Zs-P!C}8($ zDS_w8llpIUrGT+6zijM>1qM{k7Y|{u>+2Vbngv4<45{1O8Cd_6Vwm}5jJqw;(w|lW z!z9f+lD<(_CEmO*X|<}~3QCKd_SOXli@0teBG33IFGD>F)tFdp``ZQaN;bk6WemQP zBFbJyZKO2<1oodPxY$2Za7ZLG#i@rLBq8zYAoB=8h^TReJ+Y0ajuwnZ7?KShb8xdl z!~$Jo<}kLmr{|HE!xU!nAmuWYzf(NZ+4_O;D<1GBDiG<&3qaXnQE55A-1lPFebI7t zre2{iv7jh#_+rogXghha(~f9B>*m6K7Ze3 zo=V#h-k<+WuzxZl33r0D^KumibfjK5=g1j~UErBSZ(E7xCE(mh9|zbl&_qM4XiToU zo?3f#Oq6%ooElNmdt;Xg=xBCQaqhsIoLa9uCc1HZl(dFq_NDB1bzz}_?X|%B@VWO! zp&_=6yd5A?SGVd!)HXB!j3GGj(A;U#OY#~XzczJDi8oM3>Q2X*w{{ZK7KFiz%2I@` zQpusJ;u`%EPsn$z(>d9a#&WM*NM9Gkz@d(HY~Gof!c2G9tZbFS4~}e`<4Rqpz;lJ! z^NwwkS%U>8I#-Tnq+xj~%w5=R#VpVit!{BceFJ*GaIloKJ6~-&n#7qw>^$#i3_$7q zE7%Ac?iSlke#1n{_|<2=Fg{<`|yd;9bTp1=~ky_ zmxR=Vyem)y%aET}vrjZVEJ>@pmK5{tkfr^`=M!(-fOFR?9eT5p8(* z0EzaP^APduT-gKwW4T*Z@@%#BnK=Rdm(~U7K9&~#X6L0Jp7FOKU0T%!4W6fGk`S{g z+vZpq?*hV4yvQz4DvswQLu#ajV7|u297H3k{N(1$TAmFD>G0Hn&7Eq=3q4&V-1+D(CCS3&tq$(l; zBArmAi!|xIC3kK8X8zxI=FEL&=Fb0lt|sRsJ3D*-$|~=A*Bc*cURI)|W}!w9gjPlQ zqBeq%z)KQ@iUR((;?cQTvf4DQ8|TB!7&7(gXbQEfRG^=c!fR@%%9ij8_Ykw zCc%&*r0{V7JW1g-dMb4#B5=McPsKUBZ}5_^cl$Uap? z6-BradUN;>u^*9#@lsGykW*1oQtsJ9MNPv%OS5+`&EW$F=^2i(u(2LvIeL_xLy(*O z1pmpSM|mW9`Grr5iHWgspOrZyA}uH?CW2lBvuDp9n!Pm4w6x43$B!Nt`EUOr)*$<- z&@qu>P9P-vF{Jx3#7_ttjFTMm`_G@Lpc5q}Bd4IG+C#k;PAJ}oz)F#lkdcy;laaxv zzVJChwx9gK@zdui4r*PeJaLObOlf7QUy%-q7#%G$=(#nsLIwuh(Jz55RWgC0H#j);tk zj){F1_xxpQT6#w2tE}v|g+;~hN=nP#f2{pf_qqN{Lt}eKXID4Ar?+opbnM6Y&xy&Y z>BXhxmDRQNjm<4|Ug$jk?a!Z?{bgSJVO}I;WTa%2=)5o_x6z63CnG<8n&QBDEz0Y+ z4xSLXL&b0*Jmt-YJtsxA7Z`6iw^1{3h>dVAqEq`lv;W$}?*5Nv_U{w>KlAECXh<=z zd8GRhEV4-m=1C^`zw+!}zy}HK=;zC)+1wR%6TVSpHj9fo;JHOTj~z+cxkW^zSBS{Y z9wIWMNg2~-l1@Zuh{#yj=0qkD8P*^ohG834MC7P{iwu^Ckoa$PV#bT~v;1qZ8)F2| zDI&6Qkcf1r5)KoQdbZ^j_(Dw*p&0SJ$vBN$Xd#>-B8gu91TrGBn)7H8_dpy=F+-}> zb^pSTPj3lR>$I6?CKkjnK?)3-nE%p~G5w{7zuw}p$)a|CuJUKA`wRBYX`J6^rMSsB z%Odn^B7fjzL(X)}J9Z+{bf&YhXI&ccmzv4*x16Ux5ms!+5|ua9)N^l>1zDQEQ@wPh zsAX$Hk`Q9MHC;hOJ`xei3h@yE2ij{I-reQ*g?0Fo?>#;L1@Xcs|DWOE`21RvmbCGN ziW3A!oGyOv===lLV~ZIDyII-dJ!MNV?AK$hsYRchp0g$$%jTO3N$Rbxb)fHE^}T$u z-&2uoiz;8oVE2W%x2va^?~T6sVa$Bj`_x8vw@L|0%oA!&q8$c<;V8`-(~}7boQj{xg)5!hB_=rNP~O*B<@2I`T-Z z{7sGKS*`$%msO67L9=_ZTD%2H0*?pxb?v!Q7AIN09pFK_TyU;pBX&$}jmv-k&~o2d z3HqJe44AbH1+HLK8T$BRPR*ZN|Af%hI;hiO6!F#`a_@<98YDDciRl|X&EZgaSVMKf@1(~KAtsNLnjO8E1T&HTcV`V4%>8_^EXYQFur_rB z75ft^u_#1Kf#GkTS9a-|w$*SQxwn#vjOzEZ)&*oUcKL|iH}X+k*tyv;`*0{0+a4g> z9xNWn5K6(wz{C2nVrVR*XiKBk?%_-42%Kt6?=knpYb#`a;!|!TLq%!huE!40EZ_w< zwgj*}YA6&I#J*JH76;hTYQT`y~`2yZ7Cq)xG=|7pwauw2Hdh?<_ls=ugTOgjthL z3-!(z`pVbfSzL3~Y}Pq-xUnZ`Yge1SrVca9@J&?rX1J^+_zg+hKU&;)U68^Uy4wf5=VB^Bb+D4Kg5Bbh8lFU62J`&wGrxXBe96Sp zT;DZ<^o|S>G3FwunXH=Z-B$CjPYSA@NgHagCY{Lsm#OiMNd3!um}*5mZewaBjLr`K zTxR@9{yI|x^AGFmk5gnneW9N+Nx&W2(IFxcDWhy#+t@Ffl&vgm_5OQjw#H*3?XKr? z*8OOrxygT;oa_Zn`0+D4e4Tuw1%cH|+B;{72yW3|YFjUWdKtUl=hi|?pxbsc%UZpD z#Lu?1bMiD6dupT)vdir9b?}X8gy5F0NS}wVkP;C&LuB2TZ*2a7Tj-5?^^%U&=)|Sb za~^%lH>eNy@NgeE_&7;*RdvI^W@t%MT9SxdzlPgoDPAW>UdfE*hWwPi$c?qYeNm-x7g8h*K4M?0?-fIW`&n zfy5(3B-~7uh!iMSuk6R|!T;rm9E;nce;s!76zg#~r*P+iZ^~b`h73pR|08|Fg-|_8 za9hjsxw3#KsN#aKGXzKzwyhW(iw{_7MAj(%)&|=a3BK2DF|)2<^Hd_F{(?IYMg$NG zk0S(C%0MFWi_eaTr0P`-7XeqCwH&5(% zeD}vo5UwnUBi>2Bx`@c}JbyyvC`7a8RgVi^5E09QOii+X{jL=Y#OGh_f2Lxi z4Q}FSLPWgSb`KtNa{b$Yjs|*tvxOXjYFH5X)&vo;5U>Bs0Wr*)n}^V>09b+_EBI)n zKWBkg0r_rv^;k>o&}zHmk0Q)+mv8S#^(K>8!PZI$1XRX_gnvE2fXT!$`{Gr1 zgZO1s;S27Gg`O`;u&vcr7+j0p|1IZ*c`8Ny6wcZDeRJ4Dv5=cOmr4z09rj6yBqr9k zKDqCnymxgmsV;)P4!dtZ5y65hoHyL2)am@Sa#O-rUb9qL!1ThB@&tm}_01U>+Be;t zzY(e3^-Or%;%g+iL-yfEqzlY!4-k`uQ!yL&MqXpMx3&8-cijxP{i?7V0_iH*KHBZU z`DR5!F1#z$nUsVrzD;}&1BUjW_Rn|upM6MVss^n;I+d}vrdc|Tt{#X~xrAg@u;UXcE_`dv)lG*{oEYPx|& zduB)R08WWUtWt05)7d89n32(j^*#mPH~AaaE-dn&5U^5xSAp!yJK-~vH23<4A4REM zL4YhpX-hQuYQgTU_uOpjJ+{bZkT!lb;rbP~ONN9^&2fdUrbf)_kw@9@^z;owf9SdE zEg2a#_L~KB@_+RWD9D=iLp)f9SnVa(ajSikk0JGA>`GdFPQohDDC#J*-^^{$%satl z96(rp@n+`|0-i`EdlY+T#lWGMQ70FNSRy|z+@VTpc;a)|CZE^(b}L}A zXEXQy^ZK(Kd!L9b>F!`5qfvqz+m7+Q-O#+XsD`4HMZhh^h;KX&>({~NU5iso+Rent z;R6UA`y&#MMsA!h)mfFrZD>-qbgf%KEdFt|y?UlyXqQa?quV<7y}8k<1-Je0B8Bz@uLz&o@K@WbkV?kp1!@`x&8L^Smm! zAOYfZG`7!h+kNQJ3X*yXM&ymgk7T6-}Cpm5Y3UUL-c|Vl0ID` zadsmAWvlV#459dI=h|%{3tvY!wxK|UaKGeAxK?r>&X zD8RKlj&`|*$XuYLXZ*^0>h&B|vrE!J9}xoo0&7a9TzUT8N6D=D6?Zn4&{CkPvV0g{ z7h+7&<#V>6Y23hpt9z(Nj}wR7a8i{cAG|9qi1fz|I*@9eUE z;FO+*Z?#gs_c7Zjv+&Po9x0&vzNZ}*GdD}$sJ|WTJyBLb`ZD@T!rfP#k7^L-ONj5@ zZE?f>W(6kumx|teqAw*&`pGwF_h3T-DoVcLy=vaC)=Z2dU8KSrVAjFfC_ z#J}b;oq1Y*um=+<$97~#|Ai4GKC*xxzNwD8OWD};TgBc6okyJ2%?GnsVfZA_=d>r!p%??ZoAq%gWP-LcSL(bk`7mZo>pxdaCEvY2Pu@wx zdUoFutuNm&?n$3)A(@`v3D=HdL?SM1D^4Td|Nb@$GVPKFoZ)wmeL~^RU3Vr0nh-Zb^7q74|Yfnx$#Y_MN8e(CFDD{FjnFI}Q2EABU)yFBzy< zcgO+J;bXPrSW|(}`Av=CQECiFfV%ddzvJl!m!TY6f1%XuZ zf44;cc8rwRi?rXmN)DtenbrAA=Mm&h*xsG%=zENdw$cSmZv($SykR#}*1m)wUR*%I z-$xMO@{xc^1aT8akZDr{k$MK?zSE2MTGH?V#SJy3iwF_@n_QV02}kxgDI2&T$linK z6N5Z|!3+l}T~#hAP)<=%F=7roe)b-On;>Zp}@GGr|8 z>B{T0p|o~NMEyFmhhlq21l@jwdZt==>u#FHn;{Gm{6#H-HFm|3ETCa5cEv&^OvUiC zECs@IBZza?QH7ZFd0v7Y@geDos5&)8eA@X%mK=GeD8;dRwH>RY@U=hueNoFq^8axW@S5q^*S*3UrjYs zwi1I)1CBQ_2n`8NRohf}{!m=oi`3AR=#G`Vxh5_UfTQh>;TP&PQbg{NZP!Mr1n;E}oS0Nnxx%#Sm=ysP)^{>U z@+VJ@o6%xQV`fw|EuRpl*w5!=r9Z7vAj2neIP*F;IPZvx@*>jiBVYYF`);>}T)A&If5$TM@?VYkNdL-auKTp;*{MoCms}up{VzM9E z?(HCmDNX!m?v8WWGt3ejULW!$36G+xsjdXXFZfiKvp|P z1K$++NzxigaoKbs1bHTv!x?Jp^EluxWk5fNFK+DK0r2Q7dW<)R8-oba!?`OIdRrE} zRHx;nO-x!QNBUkygb<%rpxq6_jMh`Rr7~|c>o06v8$ZMkX37{PiXaMBSKBM!HxiJa zb{=CxnM;&Nvh@|CS2+cZT_z+OFVl#Zdr1(@&KO%Cr{s0+RPq|$sUuOm2-5LUQm}IX zKR~j86PF#tA_$LPq4*%%aAnw5YS;|fRRNolG|wn*IPvr0Pz^R|r*!N_sTzVyhdY?| z#bt(--6Wyamb;4*%nV_Tsai*wmqvmFtm<%b~Z*Mc|& zs$-Y}h!@7`k(#2CUl$Vmx-i3gqhJF?KU&PJG#QgzJwC-gPL9+(exu!<5@v_-c;-xk zm@0a1*cu|TPm7+tg?FDfiqkmRx=5TTqhZ*h-$wVk?J$zoWb&Rw5X&?Te_f=txSVvZO76AYZRuSFt#F z4RQEAgU`z*ua=iF!(5GINI-;B$0y|V^CbjH=E#lVn0bKf)?%$u;^X7fad9ck)yv^$ z+Doo|e{`|b%gd{CXh`EaqW|tP%RHHNcvNe2CItoi)LOqI9$!sxyP~h(WdCzU+y1L% z;kSfynK;5ytk=$BJ@3BZMv;hLdMY8WcJ74f>*%D$J60L2uC6jDhTGcNb%dW(;-MNg zTY8(OV5yQ9MegL*K2;b2f#AOe#!=YnLg`a~+ zq34@k`)OYdf<&>S?Pch3B69HjFTW5j%Vof*Cg5kHIm(RkckJkbP7=Rc7V^faH(C}p znBQ3X`V#xfAkWs?dZTq_(L%@o_|$oPq=qj^tZ4df-slE4Veays2w!EKofp zRDkD$M^NqY_-3x0u2b-U*CBN$;G@hAl@X@XVfCrvcHOTsG87xkMTpAj+NvA}RJkIl zgKFWya`GKOHa=u`{?3@5vQvS z=Gt-UioN!0FO|3=wWJdCxFQ>{uw@4xp=R>3?bXMT*D!{_pdfK+>0AQAFG0pzZ(v|x zvpS^i1`Kv{;y&pv)=q(s-+gguVrkUke!or<;s=x z?CevUKC+&bCcU%n@Hl&v4t-%SVZhHsO&`h1?;OkE9D8tdaYG^YAO$65*U(VG@_5%*rRzd2Z8kfUFqXJ(d4awp)_sZ+XodR!&_zFHO<3Sn6>4dzkG z9I~#5uX}i0B_$=D{`ISKd%n6ra>z#~^K!z=8ZbZS>6w|Xo}O2nYBAQmZ!Ml<{i~*@ zrx{>vOiTnbJ3O@tWi)>|$L@orR#Z|F7Zy$)95g8|E}m*lk>47=NAJ3|V&mrSu6>mq zNnXh{C{*Or%WaL|R1g>YEEWdU@^BT{&H?t?b|ig>m7pFg81M z_b#4Dfhix8^|`veK948tZo5sk9)}gsl4D0qy$p(Nqa7+&^#**Fo8ONF9zo|_W+eE0 z^G?`PWsc5)fjs#jI-B*mfxa@A%t62Ho75DMPf`L-PqDDD6nU-MqB|HbaZ_7OEu^ur z@zuti9Mh71bVHs$XA!viS}rdy@42YelR@8gV?$EmEdGcp`O~H%9+f5n#_x642fYhq z*UMULnqmZO?d_xbXCdO|-)S(9Xts(Tt*f(>BdGR+0ajQg55p9qU}0eaUZrhpY+O}q%ERz7Kn|(td{t0@ucgQ4 z3i(c{8CLsM!fkW)-p3#htZALfj9|ymfPa)ONb1}<@LTnf00c1;mz11I31V$`-0gO> z*<2c5+VuI}N6AnsUgJ=L8bYB}i=L-vSzutGD`CfzniBt5N8D$$k@%*bwS$yiI=Q=B zuWZ19np~KN>`cOC0i#k)rVvi->}r+oHqTL*`_+&`LfsQsqNmJCtNf<-Hio$gE}Wju zi9Kg|D!To{JxX3?BXL2@@bjW~@1{RcvUQ#elYSW!!*t5u^c4LtQ%GY|6GV>jRj;nL zwv;<}Fw_)ehu0Is_m8D7C$|()Z`_~;MbhVaF$-qmHCQgx(6gANPZH2rfcB#P#(HNcKCb?b8Iq| zL7pB247en3d~-V+qzt3#&?A!5=wi*54CMz0Q^Q@AA%egu4YTfc=H?e=WMm*(Gtf{W zrS4!KzZb}Ru1pEWpi7VoFYKQ^B_tGEcSB1_Dd>H9IWNOL#ObCqYZ$A~!g-mE;d_Q} z&G(FLk#@*Ayg&-@Ii|v|VEIKH5y6mKh?7FefO|Y@_W~vr9-ryv0lD> zeQucdO|I9y_Rr7V&VOg$`_iobi48=p5vOPOSg4Q<5AcKcyKDW$?z4uT(up?L%5a9a zCj2xqJ7Rs)w0YAzrgnyYnyCHGP7w2h>&Ia3n|QEb!Vs5i%dYp_yiQh>NZjM!S;NEc zaGSL*B$f#93dTnlqZ-UD&3n-0=$MM?wT}VsX&R5HyU~zT+&AkU*Zgyb>b6AuCD=;X zCy96gVuwRXlWnodinoK@i|_OB5qi+CEU7H0%XYVQ24*FHeYBnZv~lTxL!MEY_U!?; z*!S<>yAIc2=2MJkd5u8|OHgZ+GnMh_BmG)3bcYiC74p#A*$Gebz-lFO?J zzbB!g<3s_L&{ObWpyp+uwt<~ZO-)5p4?mNRR6-0#x;C%MR_lCOQp6JDW_rum;zp3{ zZGNWmUt3>)h>92By?gOOx36W@j+9P5UUP175zv8gHtCiPHCv+o2T=#|;T02;{G}yl z$ViZ{z=zHzTt+d5MoGT_JYt)o{Rc*zevsCtpHF#q?HVMSr91>;C}fl$!+K(IKdTN-5=f^ z6L&8tC_I4W(*=Mh$IhPA!X4Y>rnR>=J@!n-4&qpxT~%d*F8k#dXMh^OAZ#~xw^mUA zKypW}O?N9!oogtU_0h#n7}S#|bO};!s^(PTG*Zx_zgak%6;8aU%2nnIDudlPa`!gMe z1d+|n%2FvA^vW^3b}h34?=KO5MI^3sa`M&I)>da%SNcKj%ac8C%pePQw6;F=^7eLv z$ad;BGZT|D>1?TyU{`ndseM#m=GSuogb4@;7?wJv{QQ=X1L*->i0$L*j3IR|x3;|U zAom8*vvGmWFrdkM_zq=}eXj_dqCPe8uzqZMYO3vxS-tDh50wOIPwj++1gYC|FP^Z< zc-75J$fZ2Ekm92`IX_=LITRysEh8g?3(ei>=?dU>hObTUupBuu?sO(2O`*nIq+#57 z>hlrD&L_vt6}e1Y{wM+g=IWIz(Rz7?=@6ND&uwd@YfJePD(H@$Nv`x>pWav;lXCg_ z7|#EEey_qjBz!5K4JTO|F-b|gsrGckGUxQ}eB+$nLMw>IM_9?Y-jN)Ya#cR({>#+t z&YggTBv!A<<2QTXDlGqO7Tu{Ls&l1+8Ie-bSpxv@B5Y2q{wi#-ogb{6o}05gqoaP# z*Vk9dZ>RJL8#e#jx69=B&ckMVRu1muJymRU)06gG;#ekwRU-aa^;(@w4V(TM?+73z zI>m|yu!2=fViyc`WoNl%21gjj6*&&cDx_*j&}39vi)FuiCs7GuXmfX9);%XT_iJnN zO_4Z2xt;y}Ir;g5P-VQiMFrtm83hwovdf1B42zU6XLh_E&(P*QhV?TlsM^X-^xLwL zS^RWx(=NA0-38DDEe#D%WWy`S1F}i;FK^LOA48xNf=B?#(e1~V{SwNLQt1&6mA!`q zJ71i2$v>m1mU4;+=*bRUfu#oSVi=?ZnPtVXbMBMh%Upu%Zg?$xproZ8fb4wf(j{9P z8!h&I8=BmTkOc4Qk#Zpgt* zI`|4IK2pdvfO)(&@f>H|-NIK(W` zP28&Es3{gwx+N)cr~(jJUx{PVw?rAcuXl#*^?ZFRnfR~DjT}TYgCT6h2;tDMD(^+) zj#Pmqx=cxTv^7rDst2hdBQCNLQ^5VL?d`ul9q;PuY9m2!^|pT&1ycqF1|NbrAwQz0xvna%3~OZ{lTK_46oP}5#k1d=b5X>0RC1*XEv@GW7eB~X`HUq9k}xMpTG z8vvd|Njy3>-Kf~uoT{q9r!z#j;6zIj9)bhJIUb+kldkUW+2!T^Pmy7#Gr9%_S#C4k zS_@=IRPfukBCv>uop-UUVL6qR#NqYV-h40pvl^YEjrr?N`o`j{{&M5~nN8s?Twt zt)?bGN=hoDISs9%+BcUc7^xrpa4w^kx>(X>8;zg2Vqw8~jt+yQJFm?cNb)rxNkE+od7`K1*@^In zii(P8nu640LnQ2?6=1N}F$lF{u!7(fcS{ftX;#RL?nhzG$C0U3ukqzgYFQFfz_ee! zJcKi8j*R>=%mpA)^4&Q^FZXH+3>p^L1kmE4D_god9p|XhI&1(*K>meziK3JBf!j&w zWzW%V&s=c{eewi_Zb#`3Rh&!)WZ6F((=>h~ z2#UvyCgWw%8zjjd()lAJ*EtV7;=P6gq)mH7ViNqf3$3)if9L;QX>o+!z7G{pdm!x* zUEomwdSvD1s?S%hC!PrZ66KQtL0FmNWm}sT6uyU~5daPpq$iMMhMrk>dD5EJ*4C&z zJNA98pF@wh5qH<^pdNrSZ#&RtC`NjxP3b1VW5FMX?IU0SIRC z5vvvBG1pdVNa-~<$DUv1XM(BCbYF7>01nU>4L`K=pIE3Lh)GG=en~(i;zO@t_8>gu z?&Skd?S^~p^?+_TcI;Rnn=~HEUEn=%w+B5l!Ltv4pvNC2fpAlFYfKTWoikG9n2c9v z>+YGObbJRMF*6|$uzDe;d^Lxm!+9cU7rq*koB_ZzGUEZuH}mA&oaW7&0?}C6TU1nJ zBxqscHuwE3Tr1(st&gwAA=rZ>aYi=WZRrg!XWW_8b;=&(*%YNns^SMxDdfA~_AH=EOhTOIKKF8CpvKz3>uGJszg)6<0aHCID7>Eu zgK)Nzm=^ZZ>_rqWV=640S)hIp{Uk)Kr_v1u)%H@s*1}-B-dVKeEocfZ7GzyD!bRw@~*drTS zN#JW-^C1or@q1ZGoG~?2Gu?R|Kxmb&h28b{M?L$j%TI=#n3}8n8Q`$4Qa8Um!VDb) zyA9BaMU&g{+ZYJL&GC|9h5pR#)cdH_Do1u?oB{H8J6Un6pRp%USAqA=5KZwV#4 zIys`_2;>tOw#yKfX2iLyv$fUqDi%2@h=BlU2tW4Wc@*j(w_GhIdnjPP%(Fv+M%G_` z1)>#XVDu1fTcdgaA4klQE^*>-jQa^u|JcW>vV8FFL?*v`n@vDZ%@uA}< z_n{X&uISqvYF)Lm=>q_-2&s8Je;FthsQZs&YF?>-;aQ&cClRJ9^Q7EYp8{k^a$KL<{|unq1Z!!@NKNzBqNJfvwDrsl(R>;Sx2B zknd|_0Lr07QULVhNP#)(dHb@B2Oudg>5mN*ox$_ce#Oq6z*rx@lY1v?IxFitq8H*1=iNC=PjV| zo)MU_=Fwm7ffwj+qrHtlXltgM);t zXPxum;k=ST5qpECSfPmnsEGutL4A?Z)L(3ek1BA1*255Cc8KL&g@*rDhe-TcduTW$ zKLq9>8!FS+MD~G4m zgeubx;e6T_R%wE2x4zx3GMf~s-fVVk9Cv;bi4JK4(5oGU(?G%HCLH1IGaXX`jXpJp-7}}wrv~at9`;d)ifJ+>a3|bhzEE zt;vpl%khCixAFPLh7}%f{MHJSE^W=;L6JPZYE9gC)(#gRA>s=JO3xeg4m+%V8za2e z)(%xIkfUUMi_H29;BN9~9uvQWpG2pd3bga?N+vcTVzsxzvoNKWpEUiW>V7109;Hbt z*9UWAaekhvG1X9#qQrAV+R$dgP#f%RHhdF;f6gD87`R2Kv|%?O7pG_{L;dm(jwZXF z&Hwwt9Y4W|b${YIlq5Y_zefh_UDaxy<0S*+*<=uQC&h20mhEzr^Y`~|Idc`um)&`i zky0{*;mg+Q3~lvE(~QmTk}6JU?41YSGu?V^n5WZz*ePRUX98OQwyM1kT;rJ^O5;=$ zW~wvTW$&O&z+1yI)jhCwHq$w{-R!x_brb4Q7U|uUj>p^daCoYDW_7xNFkQg&FlWWP zdfof`I(j(nIxsuGdsPp^ZUIc4?jIsZTq2@(I6GH9FT1;pC5n>YA_soEE6jh#3|2vh z8U56tYT<#<dFf5@I;2?D!r)85|G?wCpq=Zc|n3@qsg4RU36e?PF|USn1=5-g9k$8P(ih$78n(u^se& z3#1vFY?iYh5iy(VsEu+K0I4lR;(9jVyqf3YcE5Qel#2icUnaGCwtQ$eAHqc*b}JPdF*I*wMhfBn zAVk3dH@q>rKr7QZ1t^dIynAN(kpJQ#DYxmUs@+*fyTJ-id(-5H!rz%R??crySGDah z;5m^X<++qL=rtpNdUwa8gWSP{LgJbu@g1x0iq-&>ui39&nVP}Rh7o+Dy@cJ!#|J25 zShbQaGzB!HlxXt+eCAw|I^Mg@KtyG^rs z&K&3$`N$?g7YZKm8Yl%Qwm6|Poz#*Er5yC2plH@YmtF3SaGW}XJY6WeA7F;fZlOPx zh6VVWq$&qT|N9^PUr5EGZhru(NSr!3PVZE-Hnh!J?`VAa-B-}w2Q7lFKb5fof6QGW z$fC9_i82gQ3wr=!0)mInA(lUy}gzTL0O^18zh% zThbalX)u4THADKf(^oLQ`Bb#(o9lq`8z8W!poseGD>oA7Z6m1!!Hsq9b)F(~k*ury zp949OfWp{@a{*9_$kUl9lF_nrhbuEkBgzrI_Fs=np-5Z~FrBG0bRp#FJ|C?xbwz-{ zCN~a*mJFy6ILOQk4K9cQyK(?|Oof4baXGVaxalx7M5I|K5Q^F=ETHW+>!RVNw^#WC zjYx#2pMgoMLMZ~=^@tOL&)IwT?gcBRk|E4o%rv&<=AWL3w)6kW5){pOy`Ne_ZSKQj?PB2Y>inC3Qmggp9kCd%6Z z&5iPCOo%o5!}~~QL=Au{IRfD5iMVRYdnpcCOTOwFu2Fmmh%9$5q1gmUWW*BXF=k-9Z{ABZnYUBr}~9hUUssc4b3 ztnaqPEZvlH5on7VZKFWTF7&ZVtTKn|&c-cYprHec5I}Mua4^X0zPpb3lE4g0eRz;$ zUsyeD*=+qJ^7{6?$+0s|a^U>m+wZFo2Wf!MO}IUX#%o%Gi~)xhO5|j>qrLrO+Dk}{ zIyU4{-HksNt=&SDwa*;wE}yT;K}lpld-nF~Ko(@6`T)RhfiHHC43I!4p5}1xKIqjh z#;F5<{B zx@DlD=v^q~0@QT!swyYO(gg~ZvT9+K+58cSwjDBmxB!a` z{EfT==5hVlcM4_Loci(lBEb>N8W3At3*#O?A(WD$)Q}7xX;hJ z;ia0{0skgNXQGVv%EnmO%p>l^XAv!0AYqAZviQOo01*S+$V2)Y9S924V^z}`s2@=K z9sc9Ts3^&y#9Q?_^U3gsD2hYd#n9D-^sECyo1=7;=STz(gw>;zCEA5*;7;o7(7?*1 zCvoE`lUoH4Kv`|pQT_0*c<_(|RRpF(p0ra7-aO*;Dm&Y@5$_3$iDoM+?6$#x*GwKt z%X@mB&BJJNMYcf)4?Jk)+qwD4L)9A(K-n?NyQ_|NdSsC@WM4>K&tYizPu+O)447qF z8;lbOD{^yzKTBF#{WkE~ZDmpmB@zwG-O=kp2Z@S=({M2?G!zvO1<@UAUz_b;-I^(w zfe4~Q=yfi6Bb9Jj{xTKN!EnJ|Ui9M7Ac8`LHjdB%`Y+&-p-EU+=+cPah3}vZKUC{; zZmv`wZG%Er0(zve^TJoZ`0anTrHYMKwE$I_CmJPofReUsNzD7xC;b8xJFJmqVVjijrBlRuRw6Jw=-!4HNU)1W` zB(!`K+D^Hhy(1?FMJ74!G7#}%EEfCFc=w>eydCx(bWorjqn7F}t-BEp$CI$L*LGpSz;zK6_Mq|#6L3dk{*_$_Sk})-4mSc-(3oEc?uTviE`ZA;Z9AW0W zxnf=Gqy2cUaAjrfDjV}!?|cOlE>8%Bz^WbCNBscpKMV~yet8%GV}Y)2 zc2?GD`%_&r(K-Cgtc;3wH*a2n6}w)D(9zS+08(V|+4EAS@FGx}zdJiSnYAOD5N2S6 z8RpLHrczmXd0oJc(UggcvxI`=P#LOr25HF3Cs;z!6b1}uul>8^rniRZt=jIA@72}P zIuC81*B*;Epzl=w)RXURZ!WB5NEMb0tl1n44H9$UV=8OtsIGb~n=O)-Rv0Z;?-#Iu zI0h+E!J8XSG-ROX5Hpj6Hr z)l*UmLLFrf0_{*xZUIM-0RB)zfe5yJ|DFp)SaYIG|I_|U5J^xapN#jK<_5&;=g^)4 zZ5wDWn1BEWT?hzspeG>?olI(I#l5v?LSZjNZuuBIVA;7j2kJzyQP6InsyO)!ikQzr zq(wzV)<8VK5}A@B2Xn8LTm~gyY_p~4FS6mUaiYK1jE8}Gs^$(U1RDu+>45JR0Y@Aw zwBR%=XffJD;~SOuL9#jC1OgXG$lU<@d3<1QT_9}g?C3E2Qh@*tf>sEe*bT#?IoE@! z3=1to z0dCV8m_VEZP~X+tn+5#|2|yO<(8-tzl^hhdziO%i{9#||t<<^FF}e$gY9ACoMQ$^C zsAx?vw&|$_jL6N+4edgv56RS|wAB2R>UBbuA%Z1R!Xl{o#2ec1fY4Uv1 zOyfBVrn%LbDVq)~^kI?J0x`dNe;x*HC8heJxLDE$X5lNxf>Vm1ACKM!)LarAh%)i) zrPuXiT7Z%q9fg|1c@FPo;%`OeU`lMhx^TU~)twg0LNtk*c+ZNr!qtkRKW;Q z63ybrQ=h7g#gd>>xWZn3;Igffd}Cseuh!tY(DvT?Y9*TrqC-u%u<);Ku24y0lC1CZ zLjP8hfDn2bw4DT9d7Ah)Es*I!kP8bx_q`0lJ*xA7=G~G@8L6lOv|nIrR(j0BrU={f z`XKm2TV~w5aUN@IG!(0&Awts+4w-23Fx*H*iCR?9+6Cr?QUuUt5@DG7v~PHQ8|t1k zXrVNYIPoN0hHk|^GU1b_2%z4aLz|kn& zm7_nSle85@cT6e`g;%Jy`TAhv%tF`@-TlBj-HODhEy!xn_AeQzy70Gql(UPYlK&~1P$bRkfj z2&Xzir}J@U)fS@XrrK2?)9|Q1)#}?(SW8rMAsB1dc#81IiikGnvve{`*V#Z{H&{*F zuC^Qd9Qs_+F_B5gL^?gG?oXPXShfK(MrAQ*f%np?61D$1d z@-7m<=RK2GvShZZQI{UjH1|p`XUEUq@5Novqd?OS9-yaEII$n?)j_WjY8qEpU8sRl zGZFJfFipd1U%S#C_6v@+w1G91woL&>u!`y^7F?x`%Q*}Tv|09 z>Ni2JPh*QdJWzF_B0}MC)%2w>SE)P@s+fz!5oID9!1X5B$}FJkw6hFI-CTs%#z!Ou zgu(!7uuw|bs5Dlut4EcOi{iy(7t7!-pbLQf(O}+)Eiug7ylE#94>aCVK|Dwc?Lec7 z;`>Cx#xn;fts#%CqRs#<`4_wQKn7k2 zj#vEdoh{HsXh*j3XLC-Lli=-PcW5_yNaJ1Eo3HG|626*?Z2Bi{EVoaT4Y`~*AN{13 zr91sH019eLd*sWNg);Pb3pW97vBbDE?)Q@*qu;BsbM~}YR5qx4fb5w;VJnK>|pdMw?y+ z>Zk}ej(ZZ&dMXS^=m9#6+hOE<@b|Vw7=U zD1|+pBe#D+{1K|!c$w(CW&la#;ax}`Iukpa%YD`U)nJIf+`av>uL$lzB2R+e2DaI; zdiPzT*EBDhVAbdMf=)~RM0hLc<;6urPN*EZRN2`2p@uv1awY&?w_iP{E7$t$wi-12 zUIJ+6V+qwh1E9C`1rP|(YSZO70Nxle;1Jt1{?ZX~q6Mf3l@E9^s_R1UumuFI5=a>% zK7L4}2*8Ga;$`7#28e^&3WQ!esBM00bx~z5HtbvgeUrHegeDzF#{xQ5=~okp-j}i4 zy)7%>U0QB+hpDv0ajB@B77(URd;i`L)$Rc*LHpxqB8L@(rCiyM2!dW}6%AuX?*w7|lppid zeUCVG!~gNy z6kviu*sR6OzAyRNsT~L?5TibPI1i-Q6j&&nr0wlpj$%!?PjN|!ovCTS-CEu4tSjyN z8!>gspoaslX*1@I5%a6Oyk^P(NXBSRM}PY8N3RA!c=@{N4L-~W5^2z&)~LTyRu5t< zbUFSYeW(i{m1;o60vTQttm+>>(*Y)q`as=}hgY2_;p50$-W;Ze1A0OkRCv7j{R!sBFo--H{emU_Z+|9=*fi3xRr+&Pk`9F3v^si z@<3$_cpsI&g4738kFUzhuc2(_#S^YS<=@zesu-&BAsKwJP3b74g5TbNFln>lAyDWL zebL_rh1H+@lPb#x64|17B;_D8RZe;e9{q8l_EXK%BVQ)l2~=gmi?<5Kjrz*obbeIk ziY&9|^eJoHY_Cr6;PRWAFKgU(X7tK`Bpa>D3y(PUpGn*Y9_`FS{RY$`3U?KHhr=P8 z&6W=VR;yKZyIl^4BR_9}-EPlU{FiGzxU?l9S7~3agW|-$yR@>u+}#bO(#i31mHNc9 z@QG!RtF$Lp_9m0*L$9UN=_{VWUwSs5NF)@G$2B%Krg%KAWHPBlB9RXT@pxSEcwFId zIDe+!-QCR>3NtE$OS0#_X}^}Vh%!N`%FSTt^?LL37UWt6B;`jf%(W0qd@bj4BO6}s zwJY7)m!}+s$phO*$v^X~e+in5? zN?ESCmtj`{lz`pRe>VH?r#t`vC<*%~27o=32LJ#iVUKk2G{7NAUdjakfEg<+scd2k zfF!+5xc~q#qvh-Ri2>j($^`&`8SSmh1%Njx7XScew0vEE>8K+gNLoz!001!K{94kI z%e4SV(#w<&001-6MRok80pORE4*&o&(nWRrSImkR(-Q%(Q?%qUM^O#nC~=_Sev0Du|cr3-cY#|8jNdYtkC0ANPAY@KfUi2v}u zq-B&F007h0VM)s`_YdZe_)q_vasvQhTDxLh{!uLeen|%;HBx>608Bd_l2%CyUTX_L zP|}YnKL7xxr5|6Z^8dIN07|I^atjTd4mB-KcE}|0GL*O z@KKfj)wBRel2g(%=#+{O^k~UMu000!7{~_t$ zuJ(^j*ZAgkNzX~TmvROGplG}-=|M?}LbL!#Qu;zmz^5d6C~p7&rd+?I|ElqO|F4?m z6D+7hQcBWR${YZIDfjkz;JI%`U8E!JM^w601RuqKfor9&m0H<0F2Ql>Hm_n|C7Ff zs$AE&pEf}bnkP|a~EIhCSY}P9uW~E?6Y#v@1kYi>erP9V966@XwZ6WPW~ez zVg$|M0&X|)=7R%7ctiKHyOD#l1#0XfxC7f4&9&f~;;UN6PW94kX8f;HpL_|c7 z&EOnv)q7xLs+I-jWZe<%PK`uFM2|dIruyAz%Yv#F25Ah_IEziGnTUw!un)>C|I7H% zriE244%#|Q;fyjrAR;27E9P+(SNw-L{(Wx%maN5ToWewEDk36U>?N+_8oso7p;gBM zdmJZlI5id#5iN2bHTd*Sa z91#(J@e6P9wBG2SDWmIqsT#554zN|Ze_$haU>qBie;cgA%KCLxJ_`{Mz3^L^y0?h$ p%IW-1c#l{3hML>zj4slQ%6#;KpWDB4O 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 B*7X1Y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5aada4d538b18bc46b9a0a5373b924fb43124f5a GIT binary patch literal 10382 zcmb_?c{r6_+y2_y#x{irl?>S=bEQb;1~x*-lv#y@%u(1$X%aRf64jQmC{t$DgODL( zB~wVqkRh4B>+X5p?~m{Oe#iIz@jDz?_qx};hHG8Jd7W$BG}PB(W8TJ$5Mm>1A2C7* z4@Ep8GQhw2iyh1GkKm+nTmzvOku0nB^ze-HHqz3>eX5rVfd?T+ZKLA|1&Sd=2}1~L zPx+3JuN*?-b_gjYAjEs&LFox)c){SHt91lzU_Yr9=`m2lbV1v~8zB}h>=%a;AMins zhrIjcu`K z2dD@}wg`Sb9aHXfV6N}}CyS(<8kx)_nhaG*clia4K$amtBOMeydQ!%T3stkSlHJK^ z)LW!vYOLrOg~mz_Ck2VP7>sz`v-_3&rI5Nz)}eOTwwFczjGWcWMBSna zY}%^CQABP$tw%J%A)OsLzuB8-+Ll5}Ge+CGoXL)&y$ybhh`3XbY$-aX!mK_}K=nzX zT@qce+fIiFFOsSEbOTTI`ENHw;{!(I7?O&3CRYzP5(?vwE--1^OfKO@{fZn2r5WQO4{cn*oG?962EVMWzPj6|iW1ysCYXCYM}-iNV=h^qhU(zZ+$ zdi6T0;$~F{jpj70qU^=iLyxM5H;)8uzx1lypa1;JQ|t<7aC}lOFUonB4}JvY;2Dfs zd|ntt3X+_rzj|*simbmRL*wniq3Xw_?=IOUG4Fr9`%0}0N)~O3ncpDQK#J|4>Uk&2nY4fB44OrlAd#pUUQO^KQ4%c-S?DuoF?L^)TZyNxtvz#}c~gN5!{L%*nDk zfz$;eQcq^3c4)Sll4~9rqR*z4<0%cLbSTwkS@)dG^s_LQt0m|!)2Y?;yk>-mH!5c< ztuJ^BMG^=vk>aq*j}}R=3A`=-v8bC7l(VzzHEGHDf|R>hseYjPFV zS#@q#CybF&<0X#}_i0$$&^gh}7#eryPr9&iWAyDN`!5_y&HPLEoY+`ygas0aSriGv zA@z1LHCA?71;4EvvED1t?n~MXM*371cS>}J%R4;BZfutXu_|7X%w*br>KC??=wY<{ z%^VyeN?4QPbe4;AZSCfUUgyRYXtaqAvm*7@gVHWxm&*l&-bXS9KS)+XNMnN95L9Sz zS%qHCrp@CW52B>r6Q6Fn;z#&Oq#SM&In9jJwJ*}V`mLu$P-MfUDv%V%s!YscAVt>I zGNb4=l8q}k+O}nw38}wR6rZkR9JM)r<+DCc&ta4us!B8q%iXG;ilAqtAnECFHkfa# z*O9hPs{Ya?!hovZIznMN`f)2>RgvgNRL5128`0vB=)(LcvD*31+j4=p6`vHAYV*Bx zsG2*gH{HyiIW?XpKZ{4z@`1WKxs$}|$zcMbWPX`ylIL%bWB#~JN$!RQj_=q3c=0)JoVl9A z3lzPhd3r+j@^-r`w6mu{16-2hI#%C^pzfD*jHr4(@#NH83oj-3G#*hz=VNFy4IA1! z*;C!ARBA`QlfkD+zc3ln(Ses%NR;|#=@c=GK((H{l#VQ{oX0}6AliK z9UL9EY}s;D{CH|Xfypj|haJzY%L+V)j!B#6Q4CT4e0(`C?z3pz*jGU|R`s?5mv(uV zHqzR6Ej4P>S&=9q@9(X`70ctYGhs$$I=|xPnWFqGM(gAS#@o z*Ztg@3763zAT~Wo!I_-vE!_{5NeA4BP+7aSab=o`Wys~V61~(+xe&xvaV$7u@%29| zsEgdXdsmDQbMw|$;}#qWbmhx@!Y67RFU3J^#LAwP>!X`r*FLY_Y&S74RjhguJqq(MCFE;KunUdkn|0-J+IBO8a zI$xgA#gB+kZ<4@Whi}G1C*_;DxR|c^vVlmm z2Pf@wT;Wr5Ju9mRpNl;VICuuVN`}2kh_Jk!l+NP`avGeRoCJH$=;-Lu(TA7*sTUO$ zTifvRWn~TX_i<)6nXg_chGJ67s*l+vDIsx7 zNJ>mXLMLXIffx~+Kxs4D14WM399kH!pA9aeJv8rHko@3RZh#<=S5-XXY zg-)~a)<<;q1LYo7Yo=?<3&y6VJoeb@tKWso3$xqW+J-MElE@(By{u2V%&w%Rr4_3B9X)zYLPFx4_49&) zg0AVYpBGJxjJ~!nO3BLR`j$|sn@f79D5sr^ds;@#Vs6|ZUJDQJE`9KJ(9qnx$w|=d zK$3xA8@GVKRQuwcTeq?nzDG8lJ+yI&hnF||RIz)*`}f0S0tTPu*k$k#68_D+g^pa~ z!o6>BFLwR%%5(g~lW-;fsT^?Nl6&_WefaR<)~#FAUiV$3cawhE;IP5;B&4M~e-738 z{+_X){8C~(T|TE@-KWn)*xtuLG!FRjqRwY+p*#E1;?%9%w~c*#U>y01#lB0HMd!C(feFiB zb4m#^9jc9JKWS!taw2(zqCR_3`E!|nZePIYjV#wM9bs02bQx{*Chqmw&jl_JxGF7+-H-DT$xVs- z+nW^nbK2UBf|f?&33%|KNnnfmEovL(iThoT@ey54oH(&f-Z|>c+qZF4s-_T8!itWP zI*Y|q2xPHu2ZTeEHJn+xx9sczGq&)KaP+@km>WD=Aq@pDOA&6L0+pUfW@#dq)ikl&)3P0zx^p%{AWwcKktu3h@_%*@Pp)ZaBV?LXlD#aVgr zi}&@$k)5`+xt=9fwR0_M9+y{t)*W+qcULc}UtPUC)OTrG)K)CvI!D6O6yd|y`5>C- zbPIlj5k#b=n|?QW1Rs8?r|wjHXX~-Rz*1PriD?HV=T@P;XT%Y!xBK3`d&gI`I){Gp z^%Q$3i0MDjzNdG!d2O-ZgO--IRUk6Gxo4s|`A|Le5t6$@as+Alt~&;P{fo)5E5~B^ zo&0$D@2|Iy`T6-J7x#2#o5hG6#@Xelu&c~RoiH^uO;>xIq~LJ?d}4jH3ze*=Ctq~_ zlTo@`aZb+ri!-ko>!YFDuH?0q#g(I=6n(s`t-c>N zRIOsNepPPj`pjGQ`nyawEsEWfZF~Im_3sY)DAnJEDSu0;xy`$>!06TTP~|#U<{>ds zt$XqIm0y!{uG>ey^R#ont;0R}z_vrFWAx1J+qd@zE&G)8x`!UtzEIYis2WrX#u&7o zX#e&)Cx0U0_zjnWt$R=3u;tYO_!P6vlAycAJIp(k9D}_R$#$w>|L~olGpGJ zBV*%*@$B4&F`vzb2*fbv5weoh(zrO@dAqfGH>xe?)!M?tv%I?g9ip!n%Ya_8Qm10@ z>hGibS{fSmKGnOgX^P{IZycFhlWBga(pdAHH68JJH**;%56%s?Mr>E0S|BGdt7D*D z&q{c=Y}pF1OEe!f_iwEGrxdT)u8o_WuD!sFzO|}knr^qck-ZuFD0RHq?TUtYN^DmcA0Pi^X#F1Qz-{j||f-mc9D@>A<<%NmS z#qRm({*(dyyKcA7-J?WxoVvD4Z)u@HV_JGTmnqk9uyDv-zEnRK7Zt;rszp)H%bb0N)DKHaN*W(IawMWLHa>oWsw*?NwtZKMBGyo8ykHrb|Y8C?{|j~+XA z4)lw_0K;8LxVHOm_F2IXbRo`Go zgcSi03B@k0j8{FkrTb4s0+akd<@ayW`gi&Lca4cxS#NMiMBUIo(*avtTH9YzQbJ(F zR`wDkwBpNMJ@nHVZ2zCSNVRqIty{OM%2s)g>Icn#(E7W+)FS`vZZ);4t`P_r@~&M{ zcsk(S-rE6s4O|{ekBf=%{&2?2+k0WY-M|$p|Hv+(2IB7{)u3hZg9j}bH}SXL-{*wI zs~w6L)U(ZUyY_cmJ?XX$NlqPzzpxWg2IBAE&;0O2i;JN6Z;%BdHG+7Iux#RgFR0(} zQcB*Y7Obp+t^ZNO;Dz67`vJ`aj>MUXA*-R9G)9% zi;Ou0tZx<`snkEyiTz^^|H)rAb3DZ23Q8Pu-_CEI@6^&2E*P8vaiS62E^%D?0TDge z=l(^J-1hy$lPx@^Ro#rE?%SmTJhbkV>K;Gd39|QIukxr^Fl7@^Hot%Wei+?dpeL-D zh#k>L^-${ixA*sr&y32FfByWLsNf+m2s=R8Y-g$xIG^#WzszS$Q!y3VuWznqBz7JL zvnkvV;N;Z!S7jNtMx=vYV~hV!*7N8j{##R0`U}Up%~Bv_yR^-%StlidAy5bBCu$C( zSL=wTLX=6|=d|e&)~l%`KuA_YD=YrLA0=>`!mqO6F`prM55+egO@K|Obn2+v+Y4*_ z`9K0E5UFK*`6s>bKab(;f?b~rU70vwDS0|f85tR^f*ZfJLg`>*-y7mhpIN4XkK7gy z+(O`DuYU=UJ}@;NKuM7{!Aob76+It+-bhJKes<9NoAaA6rZ9T>P|)O7^Wc@9{&T;6 zdEUkV^1 zY`Xu8gZ*y@@qZMEo^^M3WdG{V&(CLKgzn_B!CI|>;>eoy09<7G!hs%B3v{+HVD7BS z?}4j2pjeP6#6?Fx8f#BCMur}PM*#D?fkUt?^OMGsvi~RD@IQ>3!eCe2!-PBE1Dj~d zwRJ0*D3_LzA+8CJT|+-Filfvo3^OHoL&UM!Z@O=?-AmfCKu9V9GgDXVT8QN-lF(FG zSXR~m++Lx~=mo|3{FJpj)pin3jzv)44rch4%+7kjJ5DTa$G?0D(Y|b|bb`q8w5`~q zAL1?ShNh0E=L>^IYsezQ=#9xbI?Ug_38_ek_CPtHEq$caZ&JrxZi{WPi*1SsHr&Wx zH@Q@$yI(kJ^xgi@;h3syn;74(P zu&x~h)XM;$f^7T-s;|K3Goz19LmrB#3-PhG^EnpxM1Y}htAsp(r(}J*O&K^Fl{NHO;Guwp?h&sYpb(3ay9|FZYJQ9 zjCHAV^Miw7*Q4Y^)-Q{aNWaT3Lt69b(FafQu)h%^VCrvk_8$OKY=cZq^pF@$Oj!6e zBnt}xhh!xs_g!b*s_Cca$Sy1waR_3@bv4zsd1Eg}&2x}=40j)>gb3p0_=*92TO3PI zSuR{1cztz0j0L#V{MCg=3~bwmySrECQZ`767nmt~HUI{T?AleAYo>ke+O^ul>?%is z^$c4e`@i$%jb>HRe8_lG~wq$w{3SonKa1mPws=E|6k=B z>nq2CxOsTCO;@fw2g>GMjLVFx89>&x%C?Oa_+KCMcRDoMoO~<@G9hn@HL z;*$UR>xd(e9=@dw8!M38YCMHL?VFoYHLk9f`kp%u$Uh>ta=9S^a+m0+DD_IcWNZ)r zJSHYuj?S%iR@^=V$~N3csQK7j2wZ(HgcabwqA{ZM%Ma%4Y(snku$?_wI(4mhS2+bR!oOi3H(^_xJZLFXVlhexgJ!;N78Jhfia7Q{dO$1g3)BTR7o$jkO=6-y-XO<1*=5F z>aasC*b2Hag-jnpYit0K>tUdHOAe$*R`yoA`q#gqjP!r;UE8P2Ca*Gc0{pG`mHRjp4-gHIoN(m za>pjEXa4R5pUy*=&RSzPzxc|ae;;H`T?R2wd9o-5=o^WB`(k!+iUa;rx-dXd-B|U; zeDkQkYUKcfX4kJzPAMc zF?K=_c4h9afJzqVm87g}q@3N>2Y5=_=`&|s2H#wJz+4>w(1e9o=JY5#z%bJpa1_}< zA`@_cc#a1x&kGYT5KvyIsP4TB+z6;J%a^B_{&1XeKYEG{fF%_svh)Bw26H~ffsvx3 z+ZEhNgxfuN_H@D++1dFDxX`&KgYcLG}LcAL0CJ+M- zVfK@~{3E}G`1&#U@*~7Nc}V@+fQ~N!t4~o4*!1mn`Wg;$${GyIhi}@klgRSi-)|i> zfT#Gz{UP^()4R%{=MGjChD^R%BlR?(3C>(gebj;w<0tGcfRj_B zdokB_rjsTn?*=abQ_96q&4~s=Al#T38me=MoisG8ZWe@5I~!YC;PgSs`y1=urxAdk zb`rXz!QOJNC*fYW!y4^0ASny0E~STsbY_f$R3pP$uB4D2H(Peq$>usqnWN9AZW$CNwyS2?e2|j`Dmo!Hw;W zvAMZ6F~=A{Ti!oaPwNI9x^}}zQW>vaC2+toG(8&V#Ezrsk)0Fn&#U*N;B&+5_F55u z=cib1;pXPR|LM=v*vLqeC}wPFsY8VJe`jYf&VPzuTf>wE*oE`-ak|{-Dj#J7;+ila z=Ah?Sf-pL{)t>rg7!E?N&@mEUpZ+z~t4mZr>7GQ(r#igD&p@)*P+uS2K1O2Y5(jN= zyeB>4H)qNCY9^CYEb|6GWn;I5#MdoPZo<^Vlhq8HQcC5>>F;oGFeiGA!#tjHKlvs3 zGs#8H0`%hS*>UX5=mv*vc(Ri3PGV}JJHOaX$%mA4Qm2aU6h~f@EADxw(0@5bh%&w- zPgf^O{(%%F3oL_1qmg_kx+3H>Te0*DeiNX{Gu9X%n8Enk>Zn?G$oiaMC~;3p$a=}K zh;2tXs%v&I$IP=GM7#5Qx|LpLK$+uS4m>@W0PS(xyh4tG&ov?+7!tMn=@Y#VKj* zXd|Lpjunu-3#R$FjwWXE;=`5O3pZ$H?+6IBUovTX>@CPx9tSsP^1_}`eFpb5;>k_# zVCUPFBr{7$*Q{qEzsPMz$oo)LZdD#SzmpdsJvU=t`T^sA9XD5PRHk9)*w_hmGj@(W zMWm42gy4``FS9{)eTCjmWeM0Aos++QcJ`LuX$NIW(2aY$(YXT`MJpVJ^Nh?@yM7yM z+;6Ufkc>%$%%&ll?(&Ts$WN|CN-Rzm@*uM1Q5*P7vv6fTQMJk^oF*{L0O$3ecV{~J zZiwVum$lS)2Lhzv_Ee**624WZt(&S84KolPy+O+sO`csOR_`Y3*0Uo@RKdK%p^eXX zk#<`ewMe@(lpii2Y-JQ(h>yMWg8t390E!hkHyY9!;gP~bzx7IfTofss7NR?T_#xa< zsxTp6CB=0V7$&f@o9uMq48TLW!qZtax41y8J~*vgdesWLvvei(jx~nb2P=Qalhf<5 zYYIN4Ubd55WrFOh&uACOZ{NXHlGM4!w8hKTAHR)W)zVouY$JLxAR*`TQS;v9Gi;s& zb?TQmExdai_u)Q-3F^VQf`{dJzU5s#7>$!GYJ5@EK)@qYzFS#(i;z$rPyNchs#|uFXbZJS=bTKY z>;w~{Py&v(c~hD33zT1IgHkgayvdVQ6!J*Q5{5&&j~4_DN;WV5YB{D_!mNh5!T`z^nJz&BYjJEtdu z)ST~lob~w2_J`ewY`5NJIHg6rfx3O{zdrSu_qX=Fs~T`2hs4F&THqbbN8w6YWAvhC zm2LLjlyP1O`hf>cvwiXz$uyaq5JaJOi<-}%4L|+;?U&eUO{BJ^NM^yTGv%rqs5vfO zv_gE0Gr%BHf$0)g_`tn+kAPe@=iR%bC-KP6!IgA8Zr)}@HLw0-s8qtwxQGmP>|WFn zqmhd2{sz0HE9W)%sE6C$QlEkfyD=b+H=7zR$!?g5#u3`t&$4`Nm5Gxlw_Tm>QvYL_s__g(wfKIeTeb;VqBJ~86H?=hZNs>-)XPtl!15QG#Xe^U)XaN#8`a*_c4 z*>vmPhks63TvNJ+AbBCg2gdmD9>-Db_H~^8CT?GNbKXQ=O$k9f*b&6<8G@kK`~5)> z=PL*@Z-gM?Q3yh9A6Ir)622gKpm_Txa)kb$SeY6IpAgy0>o_6^F(vv32Z@QJfjc6Y zo7dFc29}0BwY!~1j@GDyQdJ$$4t~BsH>a8O7rwyKlonrQa87cjBM&vmU_g zhb5)5ZarXxFLFsRD;N(gHBS6QbKkd{g-H_H76{^Fnv?WAG~ynTPJd~xBUCQ$4T2o} zro-4Z5!+rdwVd&-4o;%hDDpH#7Vwdpj^`Qk0^6GDWs^n)!yct##5G2kKy~L&h7!F* zh%E6hWewM?${T7a>B=-V{P<(E8jQrRjHD2xP_F<;NOtvs))8x!D}r48p&u%qKCk($enby> z%4c_wFyTpn_}#vlKL5D(rphwWswvLH)bwHO!8AGIH>+496gn~e(Ggct2QfI;cz}aE zto?KuNX4bO>S!S_w&ao|0gQy+p&ueL;*|(i>hmoQ2U!)TW+ROZ zoW(m)w$|WlJ_+;g+i#kFRHUdUbz+qe!^eLgK`L}h({0I|YA8L?)sKmWg{S;bRk38d z%H-FSUN$O&AgeyZ8qU%+*#idZeHcG|R&S0p>WvdfjfLr#`L$YVE01gX0SGoW7%r7g zjhrvFsV6&%s?b1pN4h+l`o zyG>%(ZRCK19zvJb$Wmm<951z>Ln{e_RF65uE-zBZK1(v4!AJZ?|8Q9P>U$zse=!_n zObc5VE`^LLmOa;jcOqn%vLbl$PTm?JQnNDj*qz?c1jp?yEqoWrl$nK)SV+UulgEY5 z`C%XIz-Y1a5iNrr0k{-xHZV);U+t%AC}vwBeZysY zeuWT0bl<;~#l#<9r!Xsvxz>t<_))Q4l2v=2q}d81rur-(tKoX$lo1v9EDgJCs4*G5 z;2y$-hoea(5&rx)UYv#7$l}0X|KUYj#<`0~jON3KUkFI)l79YF#V6>j&C<*uV-!zP ziWSa#3_q@^eijg5y}vnEHu*;4EFInD!1`~h*=p+QNri=1>+9<+8-i#_PCPWl2_-Q> z=sG6q1GmaY50&G^ogdKfDkv&;wMGjTS@m3gfphgtAZxntPZ}9Ls=#8~;q+1Lc8OJA zHY1+HjYwX7r=!E2mIrwc|86gloJd5Fs>y|%YE37nLR?&28ab)LM}wNKquwR!>+6NC zD+UC(ZZrXipWGE+te4c`W|z%@)Y!M@#B`$eiEkJs8m_o(@?%AXq^2ya)zz%r+$%iVxw3UHNbki8KZ@CM@1gvh*p1pe{*}grjAau@(0PZ(9kn%tgNEmAuKmue9_3zobSnqyng+9cd{b& z;S`6=-BjiH?yrs(&U2l&YHMrfhrSkQy$>j`=&)LwsE^?{A>g_Q^N4AvaNpQ$l)>KZ z(|6SmJKFM`X{7~IR)Al3zPot)EHks=q+DfQ5R;Vd>C>m@hs#Smw&s&#ZObtFWlrzv zA6RyF)X{vNnlf#R6T6q-y-mx-4!5Q2kU82@xpnK7P*b%t_Q0J{!sX8GHwk`~al8=@3CM_*3 z_>KPO3g5eT-@RdyO2;E4`?Jv@(^uxa;N_xu?bC>O*Y#R9=emMZr>Cc*cX!uR&B?{JvCB|U zP@r?0$l>B54lW&VTb z^4-xYZ>fBX8rzZuk?OtRiHQmAyExR~Bq9tlK6+A~+nIfh12)3Rsj1doDM}u5$&qx0 zPZ*^<3JFMP5)-hSsbzC1vE4=1TF&#`n5DszogWUa<-@!gJ(n|j(gUga=3%_e&CQ+T zbpcx)zSw)QB6i!q?|lCJc`H@s5aUlk+PT$dIGVV#|L1ea=I&%j!6#gB_yXUe<^F=k zrE1NMjg2D5S>^ig-ya_CZ`mG|lgk|Bz96Mfxo=mg0mD<5mzVz>??0A4G4UW?%2N|| zO5A?TuQ$hVq@$1(wb1+Zyjt^4oe0&|dxUp^K?H{D9&rnhxZkh1b;g%3e-us;6KkNGZd%>k ztPl`l-IqNa?nnrB?bIgJ_h-O5M+3~V=i?2+kk;j)uZ{`|3Z0G2zM0`%T4jf8#>U3` zWB%kB!fwPsPjl8tCZgG`F^@o13R}c6M&U7BBqHGhsR``@1>!+3;{)t86;r z{$@eK;ho`fSFIA;VX&Y+55~_WgG=H*2c^Z222)B#JQUN?(s+FrJy$AIITF0`+xr+r z?PVbdWERRY>hj|z+Frc<#%a!>(4~BMZk^h|?HauA=JQ!2E@7sPi;c~o9{+*~S=mLEUl3%vFoN0JA|i90v9#));w z3lx~QJIV+mWzHgSnPg4^u*9- zk8R_4o`>t*_f&d3>zL{3tqI5&elX~_zC3-gwPe(1Vci|qx#`CbqwHs@$jLOr-b|gz zUb)oF%vPJ>vYwl_Zwpp?nD4HRv$L?&PTxQ3b@|OEDH#`i1_to`FexRatS~QcaEYCh zvne%uaA*5Yoaiqvz)+-@abSncBG8*U@WJAPtM-0<*VNoR@vK0u6#`(}b7Jaw4W#OD z`@XsSNqj$woqVI(r@A7%u+HXN^F7LTx;G?arM>r~a;o;5-$zE4Yasdg`BBU)XOZhe z65ED@)0VeGn5Odz+MKkB+>Xzwm%5rcf-KJkhpDm-%P>+A=?*^Y#q0%URMYnc0K zbgu3ne6jBLw6(P-X|aPPc6RSdmbbQV%E|S*r#8gJF__;%r_cx}GqoYM<9$R#%+82s zh?u*(W|pqTB+*lF$BOL%>(*Bkrx2E-$f&3&x)8S6nVFcTFb+Wu<|Y`wwb!OSpa^$} zqS2J-Bu#s>^{s7$W4lNj0Jj`$FRsDGv9|)LqT$b;el4w_`JLcd2QZMR`atS}Wz;Bm zyeI8S2IFlnflXOlzj5Pm*%}McD{e#;^`9Iv$IX?|>IR9EkiIt78ksi)WntWf-!ly= z%Nl}@B6#ix)jwD(&k>WBuDUfW#3w5H8@4L7;Y==|Dj%wo#z+#|G{jnmvNZ@x=EG&q zK6^s2#NVdrXSoflhU}_r1M454dzxx!VP=-R2hZM(J`a=8y$&~@ahkL%=x6#yeZ+&+D}Q0@72gxb*S?xYwFF%|cig+JKM+H7mQMBiLL7VC4z?boMUwlcC7 zUl$wv-dci+~64(WwWao_S`Tg_~}xjw_ck%m5VE;ug(12tAPFhU5f}ZQBgFJGbMn^e5i=<{obZ1TUn)dwH)fs>yKVSoGp6N zHI!%HMn!2aIYSU&=i?xeLi86q?C$>Ii%y%b;7?x(H|90(QZmA0FI zex+n)X10rc5OZuefsel?sDKVoGlI`>d6PP~dUPfdcWm!_ef`%>uM=8!d9C8d6Ht3W z#yG!e=?Y1-H2_yNC-#Jx#4kPoN;hxb)N|KQkoLY~VDLdvMTYaXDGP)XKF6 zdH|;0MP~3{jI_6S-IGDsLLtv>8x8R1`y;>++7e`rntf9NWWpjkR~Z5&-2$w_ukFSY zL$TIWvDv9eMsCjE5G7>Ydxd!$xq8Xadn>$74`6O95ShD6&VXt%96tasnMq6i@W7ju z0r&j(dmLCZb93>7{I=HCsqb8;Wc}}T@nXBXyRC9!16_YGhh#_S6jN-4bWPA)&m@il7XRBkH{18+t)3% z|5)ZUr}jt(@*&u3o&DHd2nlc7%rlA?fh~6f19qIQ7O@-2EG;b+A6)=TpPW+9CG?{Z z8vDHnk&qEv2Dy`R!fSdx4zFMM81SX3tFV# zOI7ZP(8pDY0Q@3=N`>mDE$0Re$lL+m=WxqRtq3YM)V>zvA&O-o-u?O={SS);EQKdn>p zg*@U8u_~H;oC5i&&E)ZiCBO_Us;f8aJ}p_y_s(R+t;3yBt@~6oG+&4)*{?9{vYtXJ zMt*+0;k=MlqIuuEWU$DlU*Ls5dlBIB05jJihky$gF3^yH(2%MWCwdo{bapZK#fvvq zs*j}Wu7!C_hif^3O<1*Lf1RO2)e#axU@={vK7C?{c+{W&yf#tCX0W);xW95|q{Rqj zm2>sfhUx%((|Ndfd3%8a(~!l~KgB0z;3ZlF9xeBVp~)V-Zu$u!lf8Da-KaEB*r4%5 z9adJ>OHxvm0|J5dpVlTD1sQ;=seC=$9%PJg5IlFa1juE88N@4#D~Sm5VRgu{gHu$r zbRZwNe&ts0eG-FD&MPDO0OQR>;Y!kJn;l@Ie`!f#hSTn)GAt8OP*5cL z9_{#A{QK(^rC1u0itFh15@V4(Y&vzs+tJU;tGS?^APF%ALCYj zK({Un2!xJ<#L(RbAnYs!h3cbzK*VsH26&kB!S0$>i*AusGQUZ~6_|R zYHJo0YI=ZU)}X5eK9^(AgB=8t+NC06c4hUlgv+A6c5k_>9Rc1cM3os$cRuT33F`M< zwik7jk!OAGAP+%wQGcWZi+t7e2gM7xa4h?pKY9@RlKW(sh66d(}eU|WF{siMnYh~#mR|rcXx*k z0L~1ydu%KkCBrVUb@_{nHUxM%E)Wc?ju>AuH0#bomH2x(-mK3mI#-S^q zl?Ns!v?LX}*RMaV%WsdD5TpS!(t@<%y!bnBAphI9>kuPe;N?&;Gv@#jwQ3P}`g6P$ zdJd!BJ53Mr)Y0v(F&d+M9T*6A1ovX>wSbX|jxTarX~{gqU)9vg$;i?z>IAoW`>e8Q z^OC5T*Nx%TaQDaBphh*pFhKX@M3vkRml5IE;My9C9YoKuP3WHs z+q0unkQyz~`&xK-d*_J3VsdbeN(O1~P%eP4Emt4;5R5PL-Q%G5>@|c@d0_FU7jqH1 zJ|&UiBo*nso1lD{(Ae6$oUwLy7xTx#N4^9Wn0x`|VRnVoC@8J35#310(0!q7iZl=K zPRZW7g@1gR3eLl>6jFanzZa5<=oDfgK%F^r=Iog>->xi-+1P~_IMczPc@OfPwzbab z&Ay`N9CqxG8Yr@kWOAQ6O`uC`;h6!m2Ge~Ca$A6z-tFNlC{X+H>Y82AcUtm@#IDwDYUo$rTSeFl)tUwgOE|9tO@855RRP+_~8Ds!Z)XC7ydXURY<#%jo zuo97AR$|->KR@F6TPA`uYl-GwX_nPS)GbF3mt6u>MMmENGT&>6%`d$)i=2=U%sQN zN>Q+81i`d*XViBk?~GAF{~a5f%sg#w9+#AhazXY{ist6*3-oLA_9qd8X;_pVY&i&$ zdOkD8L_|bo5FCpvyVyhYSSTRk17Nx&CZ+?_pXYH3XiARS5wWo?%>+4eIDT4?yuob- zAC>UakQemZc#J(E+-#Rv5v@GPm$ofm5`HvPdN+(+$;x2^`l(+XXRT_V;`3O&IgTL$yg2AK)d8@*geWc6k;`-g zY5%i%IcUFhCdT@zWhX1hEKPHiOozXMMMnPQF7B>PX6m8N{N3a>sz6 z>lqM~v_F3yE)5j5TwLi(QKCQW^F55*UhMx2rH|^E9zPsBhKP7c_t5Z@1|hgej^m%7 zkgH(?Bq^^za$Ep`@)ykU6KGi>6|;=4Lmwf%1=H|2YeTo)$PB&}?K__>h58{L+DhGJn5GC|`yFWD_9NcCu2wH=g za9b3}X}{w@%lJHt-x|eVmuey4Fmcls=Z+{LvYHeW6g1zMj*BN`)qRPeD@gk9q99te zP>`2j{qYwJOHM^<>%B^c>F-P&Er_DD_-HRct}$5)2*u6KxCydgR|?9?F4``QjILLr z%p>CCvmg&yHifb=xfj}lgeL^6n-Jf)R5H4pYXeX@^Aha#*4A8Vg1F?DnOT`5_rR|O zaV4GrYJ{BU)j^FH*{y_#JQgN+lAPStjQ|49b6D%9Q@9DWc@z`jD8sK?)9@8@Xf+f7 z-OdqI4UYsuRe(@Hl)cxjeg7_%Fjjp4Z!R#PXa~K#xj$JQU5qORrW-SD^L;r9#WwvH zOo9fPnW?E$Ax7*?^F_BL)iq`5d%CYn36xbE5^p_v4KKD?rhg zd3c=nCdrs2zx?`q`|ngA107xZecP|e``PLyg#f8EPwt*|c}|g|1Qmul+ule6k5~O} zmLI&)BwX7;aM)k$`)X8+8;}Fyc%ZW%c=zgA3k!7_5XDIv6f2LgfA`z$ zd_DuBr!%~#ruGSqZ$Jar`g0st$7;!1y!IAwJb_%7-H8^cALq-0w3Z;{keU1)v@W1S z-^MxJK@Bqp9Z34H=+^Dq5|Vk4>^f0Q>$O7Uip%;vOw4sT?gr6nA4kjHeRic7Qz)ZZ zf&H2Qns5E_?xHu=TDRPV$Kr?*c(u|y0JIVZ-`YT85>C1 z(T8g7A8zM)?$|DkRwtlv>go&hQQY)bjQk9xw3tSge%)r)0LwY%F8hB+kJ|0W3Ph={ zdtCq}maNgB8oO_8ej|nz#KOngM^G;KZgX-LL4tXNg?o*4)Yac#xOkD4jIIZ!BxF>B z1WYIRV#Vsv#j{)ldKe9|PeAoIm~<4mWj82AUB;Cyt*uV`1FNg{g5Vdx6uvK>gn%)T zc5t}j>$-c(rbxH#Ez1qK{(TUwV8eKWOan~;s9sh%^iVy(C@EDw-uMhTt-!2>ijs<| zfdOT_FW9eM?Vg&Ql{gRpkSe&r5*)fx^*6iyFP5BHdT7qWGKe2E;nsJkVsaDYrs-A1 z;=;o2AUdJfdlVt&>r+i+C4#~YQ6L&=cn$-~u=3vh1>ic01EjLfpU<8>6P2Pq5BL(U zp850ZGucA_Tvuvv{e!RTF(5Y9|6bbGC9Z+o8O1Sv8A<#IJQb~^224Yq=;F$$_~tiu z8rYsXXk%M~DZzK|+zA4*!3ucj&nu1(sf$aBwU8{z$2Dh@v21)lNM*)~bD#wX8%rYPL-g->;TO>GpOpHPb&Zy*- zmL5s2#=1KRp;JHk>dhOMeXZYsy`#t-B%s)#Y$p~U8*2@`Cc9!827d*(v$O4iN}On| z9=a6`3NX1Y!zwPVY*Xp)SzcQ`T4W3FhHXV0XIj&?%L)q%#YM27V@vJ~Iktu$s}8`F zB@S?TAu~XVd?5XO+EF2HX}@}Zz0-YT=4;`C3_t(7uV=tfg5!a9MWKMk@EeFaL$AZT zqoI-z78yC4i$z5Zz}|-L`cNhB@>CTqm)ZjE5XFPe_-Y?_o74>a7vzMBkwD zrn1URb4!aLNJ9AwDv<{sK zVo+4;s=LL{&rg=)>M}T&0WwGwpaB6xbUa_f-oKyPq;`J%_;FF{*LM-&p!zsY{WzVv z&;U9egF!U}#7LJzL+@1CV@3TQ=)dtJJ;8>(JR(qpSR5|zn-d3cV+>HAQMw`AKui!ZzMoGs9~)tVW+01=&J2tJ*J?>jFLq@)IdZ#AkHbdxVQw!~@Q$ zBrkt*!)gk=F9x+AMV&`x!L-K(maNuTk(_qYJ1#E8(I66TSS7WBdIW}9+ua9hc`XB4 zc$zRks~r#)73pI@yVYgpQXFalowlvkpHS(>TA?&L<8Cexl3m?R?im=^Lb`7De?aFZ zBnPU+eU81Jq|(ygkkI(G&?`$5_^J;&mq=ACEz@3wgruGz7BFVyPHE`{A))a8 zGio<*@e?&)y?SNqdk2`8klU(}H+FA!pu)Z2n4;MeW4{(vRHO<;=00CD(E}!nlm1%}zNE?K*`Jo=DWuVh&GYOG0 zr{d9_>K)=xj{A{G1R@s8mWr-!%xxFU;F62u#CIYzbx@u@{cB=|=G-|m$b)83pquQh zP)?A3WZoL(Fg-cyd*mHy;SI7`ul{kE;7;(_Pg^U5m>U)q^$@ClbA7HR z7zG6Z*fQR>8DKSN{hQ2Dr@}3-Wdj-$9t{7hdIN2PCua3ROYNb1sG)pLPXBS zsCJoCTFc6uVgc%FFR3j+z~X)6h1Lh8w?SXKTJE~yVqLfE4=Fn;DT%cJQn?^ZBj@)G z3OYI~?7^%|Y)^((EU0CD`^w?5^>}FfFXAY`>5{NZ-ftL^Y$dnIf17`R$Xjk2nsZ-#z_?y2( z+Nb{YIXVd!oA{hT&;^#i&N6B0CQ@b5;Ect<<~1baQqX8`ED52kYV-)Z6Pg4rK37Y~ ztX}PNXvIy^UI-KgEwh6R+f(g(WObBNTB?nYkAIn$H=@TXyvIrd6a!Gw)ipI!UcY{A zm18$poTXc0n}y;C&Wq_zI*pBQ^FQ(L@V%^VeAlE?1NShQ6h@f zOpp=K4xpSZ(W4OPzd(t%b#LY`sESH$y9Yu40AP}Ppa+1~^9fMhJV^KV0>~KAHjJyg zhmV9~Gc_}K^~;iglR%kl4w{3|);%Z?6~dCsMX%I6!P^4nr{hmZ-V4@ln6ncFay?p^ z)znNR1Bp)wkkfo`R_q`%XzOA?u@#xUH{St7r(4<}CME6BcO6a!mOX4=M~e1dv8F*G z@DvG&Iv83vFmcfLpg5IQQE|}x>M86K+IIl~+N?-7)+Xg%%AH@ZFWtcGia_QXS-E%j z?nfx^hZvIL>~aBhn~-x^{QYZ;5HC3;B?hY7F>l|#rIKibQdbOYWGi&7xG(+$21l@P zi6stCR({eHh#k|Q5j?d3wCs)L@|EJ30eDget95ABpP6b33w~Tw2L!H3T;Z6RToe#c zlW<+m>zdi@pmW~sItk9+@K{w2Z`z&KyyhP-=Gr;hbI3y+YpsExBt07%v zJ6zUubO?DDZMNg%Qvt0QZMK7a2n|RXpe|ZNcvDbP%IfnZXAo6|pmprgS@%VtPbA}h ziESBnf1wAO@AN&FicXz8`7q)uH8r(000%l5p9&jcdaetgamO@%4v|iuP4G4F-rrm+ z>&U98(9_J&0Pvrlnfaa-x;L?-d{6uk*mg~-z*|u1&_oReEZCg$su;S8_|Xz!Fge-V+K1-SxUF`Anf|8-d}d3iq$EZzUsiupgAGyna`f1x=OWSD=N zJpUhbY5qJ90y7J$HS;KWc!=M4$O^HP1P9)aZ~_c~JWBHE^c3ivCt&JsIbj?s0?*`f zz5lxW)3>?>GF>2xo3^s@8|mFKJOUgT>&g}s`|K+Q>QGf4ckCJ!Mvw+?Y_D1qP(s#m zrKJuJBIlOf(Y76c2O}E96MnS)+a$yi@-WG#ClIzbzY+8OuqqNDfv4`ifCd?Pz$_%6 zKCEwSY^}HOSx+~Ili;o9BA6HaNU(K(zS;hgoiEyVe1(Mi_mWF;Qqmu^+EWCML4XTm z8a)TO3T;zBvm-RSasShW3^g9e0hSQlVkBI7@o+$~_a%a8lX%U)yTC%_1xp(EA9P#| z#+-#fki=M~9&r?`J!k-v9( z_RlM#x2EsZd2NYF5Lrc|$nKc{2^=PKCb;X#0OvhrztBy_<97F5<^AemET$C>O;EAS zHR2nI9Dn?q2ZJM{t9mI7&k+Zyu{(#E-<^~~LJNbxBFI=@w#|aC9x@RPhjM&)Ms#p~ zH|wEY_*%tVI9i7xD{RBxyx_e=0PFC)4Y?odBh%yVeXGIU3}RXC4Y(nn#$W@Xs5?#o zG^DTc^|%~K4C0-~EJRVl4eN~umot$}Gi7ML4=>6->vtGB5p@o}U|C)-u58CWkKu}< zhPSnI!cvJ5mxlw_#T z?CMG`Ta`o}jeas$7bA>yUV@qrZ)jCmcc2)Yp%4^c9cG)muEcjTbCQ%--XjG4@ZLzq zqsf(KCIn-}VHuDF3Jjdxu**sEWDD=z{v=C1@khmXFCyw?5K0PivHMQd3J#`Wp?~ z-nMLp$rw63uPAktcM~I`INvb@r(=ZqLsNQZ@pwpgZ{K6x3hNHr!$W>f6=OU!w<@mZ zu;7n5`8j`8TBv@=~B;|M=Lr>i05 z3kMq9darBfuWQXc!cSC#b3lx2x7NLJks6bcu&erNA5%wuPK>MM-qh$|u~mT^*O<^B zMThdbtt;^aufC$nn3ucO_p%O5=IZxvExvn(tDE%q>EL+yrXzHEQD*AOjas6M9?{$&;F zk$Gr2!MFM;se?_wOb>TpLy8b7G!jb}|7Djxzo#x^$cf0vtM@4R+rI6NU6DZ&@dqwh z<|k>it%cl=StFL=v0}`y#EOFCwd|Gw>FuO{SUKai zzyce4hx9g(9_s~<484Haql-jCe>R#`@8$s6KD6@zYn|J z`}bkD(DlkaO48N5T5M{PL#SNt8_2HmaY^UW#lLoNoY?)7c#-umAn9GGt#KtDa@GGv zXfH;W%d$GhdmYDTm9$i)(?9gZJvd0Dl>2M+5~pToN!7vg6TAH)DgQdTr`h8OCuF|l zT+kTKU^nR06dJGG=R`;aRXgJ&18em|eRw(F!qL95XZoGSm{I+apDUU@Sqq11vc!L9 zbXm7FdNv4sF3$;Lj6<)hI~QGhn^YWWNL|wbDF**@diioVCO>_C@xTVt$jbQMNw4!X i#?or0d_ySth&(0s;lcR!O)xS9!Q4{5nSI^p>Hh& 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/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 2c00721cde..ce372f01cc 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -20,6 +20,7 @@ ColumnLayout { 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 +48,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 @@ -207,6 +209,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/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/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/services/BbmService.qml b/services/BbmService.qml index 8c677ddbf3..e232d19475 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -107,8 +107,14 @@ Singleton { ? content.split(/, (?=')/).map(item => item.replace(/^'|'$/g, '')) : []; } else { - const n = parseInt(raw, 10); - result[key] = isNaN(n) ? raw : n; + // 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; From 9e14d2938ca54cdbd94b34983e7c7249d8f27c90 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:36:21 +0530 Subject: [PATCH 10/18] Fix padding alignment and live D-Bus update buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bluetooth.qml: change device row margins from hardcoded 2px to Tokens.padding.extraSmall so they match title/toggle/device-count margins and all horizontal padding is uniform - BbmService.qml: prefix gdbus monitor with stdbuf -oL to force line-buffering — without it, gdbus switches to block-buffered stdout when piped (not a tty) and DeviceChanged signals queue silently in a 4-8KB buffer before flushing, causing battery/ANC state to appear frozen despite the daemon emitting updates in real time Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/Bluetooth.qml | 4 ++-- services/BbmService.qml | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index ce372f01cc..41d695ca30 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -74,8 +74,8 @@ ColumnLayout { readonly property bool loading: modelData.state === BluetoothDeviceState.Connecting || modelData.state === BluetoothDeviceState.Disconnecting // qmllint disable unresolved-type Layout.fillWidth: true - Layout.leftMargin: 2 - Layout.rightMargin: 2 + Layout.leftMargin: Tokens.padding.extraSmall + Layout.rightMargin: Tokens.padding.extraSmall spacing: 0 opacity: 0 diff --git a/services/BbmService.qml b/services/BbmService.qml index e232d19475..e303bd43db 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -173,7 +173,10 @@ Singleton { Process { id: monitor running: false - command: ["gdbus", "monitor", "--session", "--dest", root._dest, + // 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 => { From ec07674fc13d1d2e1349fd6106e3b8695139e9a4 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:43:33 +0530 Subject: [PATCH 11/18] bbm: sync orchestrator reconnect fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataHandler rewire on reconnect — see BBM repo commit for full detail. Co-Authored-By: Claude Sonnet 4.6 --- bbm/daemon/orchestrator.js | 53 ++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/bbm/daemon/orchestrator.js b/bbm/daemon/orchestrator.js index 25ee987f0a..ed73a05830 100644 --- a/bbm/daemon/orchestrator.js +++ b/bbm/daemon/orchestrator.js @@ -39,7 +39,7 @@ export class Orchestrator { // Callback for profile registration failures (replaces Notifier) this.notifyCb = notifyProfileRegisteredError; - // address → {record, dataHandler, configChangedId, propsChangedId} + // address → {record, dataHandler, configId, propsId} this._addressMap = new Map(); // bluezPath → address (for lookup on removal) this._pathToAddress = new Map(); @@ -115,9 +115,9 @@ export class Orchestrator { const result = this._enhancedManager.onDeviceSync( path, info.connected, info.icon, info.alias ); - // ESMD stores the dataHandler in its own map and calls toggle.sync(); - // we read it back from the return value here to announce to D-Bus clients. - if (result?.dataHandler && !this._pathToAddress.has(path)) + // 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(); @@ -128,14 +128,40 @@ export class Orchestrator { } /** - * Called by EnhancedDeviceSupportManager when a device's protocol handshake - * completes and a DataHandler is ready. - * This is the moment we create the DeviceRecord and announce DeviceAdded. + * 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) { - if (this._pathToAddress.has(path)) - return; // Already announced; further updates come via configuration-changed signal + 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.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); @@ -144,11 +170,9 @@ export class Orchestrator { const {address, alias, icon, connected, paired} = info; - // Create the canonical DeviceRecord const record = createDeviceRecord(address, alias, icon, connected, paired); updateRecordFromDataHandler(record, dataHandler); - // Subscribe to DataHandler signals — update record and notify D-Bus clients const configId = dataHandler.connect('configuration-changed', () => { updateRecordFromDataHandler(record, dataHandler); this._dbusService.emitDeviceChanged(address); @@ -161,23 +185,20 @@ export class Orchestrator { this._addressMap.set(address, {record, dataHandler, configId, propsId}); this._pathToAddress.set(path, address); - // Announce to D-Bus clients this._dbusService.addDevice(address, record, dataHandler); - // Trigger another sync so WidgetManagers (if any) are notified + // Run another sync so any pending devices are also processed. this.sync(); } _onDeviceAdded(path) { - // BlueZ enumeration found a new device — run sync to detect type this.sync(); } _onDeviceRemoved(path) { const address = this._pathToAddress.get(path); - if (address) { + if (address) this._cleanupDevice(address, path); - } this.sync(); } From 00e86618f1c7f13d6c949e70d97c08aeb2db7f68 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:52:45 +0530 Subject: [PATCH 12/18] bbm: sync reconnect and UI polish --- bbm/daemon/dbusService.js | 7 ++ bbm/daemon/main.js | 1 + bbm/daemon/orchestrator.js | 10 +- .../bar/components/status/BluetoothStatus.qml | 74 +++++++++---- modules/bar/popouts/BbmExpandedRow.qml | 100 +++++++++++------- 5 files changed, 127 insertions(+), 65 deletions(-) diff --git a/bbm/daemon/dbusService.js b/bbm/daemon/dbusService.js index 200cdaba20..9f06ed5143 100644 --- a/bbm/daemon/dbusService.js +++ b/bbm/daemon/dbusService.js @@ -88,6 +88,13 @@ export class DbusService { 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); diff --git a/bbm/daemon/main.js b/bbm/daemon/main.js index 0ac3a7ff7b..99f8b307ae 100644 --- a/bbm/daemon/main.js +++ b/bbm/daemon/main.js @@ -22,6 +22,7 @@ const orchestrator = new Orchestrator(daemonDir); orchestrator.start().catch(e => { console.error('BBM Daemon: failed to start:', e.message, e.stack ?? ''); + orchestrator.destroy(); loop.quit(); }); diff --git a/bbm/daemon/orchestrator.js b/bbm/daemon/orchestrator.js index ed73a05830..e07ddf42ff 100644 --- a/bbm/daemon/orchestrator.js +++ b/bbm/daemon/orchestrator.js @@ -104,6 +104,9 @@ export class Orchestrator { // 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; @@ -157,6 +160,7 @@ export class Orchestrator { }); this._addressMap.set(existingAddress, {record: entry.record, dataHandler, configId, propsId}); + this._dbusService.updateDeviceHandler(existingAddress, dataHandler); this._dbusService.emitDeviceChanged(existingAddress); return; } @@ -205,8 +209,10 @@ export class Orchestrator { _cleanupDevice(address, path) { const entry = this._addressMap.get(address); if (entry) { - entry.dataHandler.disconnect(entry.configId); - entry.dataHandler.disconnect(entry.propsId); + if (entry.dataHandler) { + entry.dataHandler.disconnect(entry.configId); + entry.dataHandler.disconnect(entry.propsId); + } this._addressMap.delete(address); } this._pathToAddress.delete(path); diff --git a/modules/bar/components/status/BluetoothStatus.qml b/modules/bar/components/status/BluetoothStatus.qml index 300ebdf86c..9f403b0c3c 100644 --- a/modules/bar/components/status/BluetoothStatus.qml +++ b/modules/bar/components/status/BluetoothStatus.qml @@ -6,6 +6,8 @@ import Quickshell import Quickshell.Bluetooth import Caelestia.Config import qs.components +import qs.components.controls +import qs.services import qs.utils Item { @@ -40,38 +42,64 @@ Item { color: root.colour } - // Connected bluetooth devices + // Connected bluetooth devices — with optional BBM battery ring Repeater { model: ScriptModel { values: Bluetooth.devices.values.filter(d => d.state !== BluetoothDeviceState.Disconnected) // qmllint disable unresolved-type } - MaterialIcon { + Item { 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 + // Cached once: avoids calling dataFor() three times in three separate bindings. + readonly property var bbmEntry: BbmService.available + ? BbmService.dataFor(modelData?.address ?? "") + : null + + // Ring adds strokeWidth on each side plus 1px breathing room per side. + implicitWidth: deviceIcon.implicitWidth + batteryRing.strokeWidth * 2 + 2 + implicitHeight: deviceIcon.implicitHeight + batteryRing.strokeWidth * 2 + 2 + + // Circular battery ring — only visible when BBM tracks this device + CircularProgress { + id: batteryRing + anchors.fill: parent + value: (device.bbmEntry?.ComputedLevel ?? 0) / 100 + visible: device.bbmEntry !== null + strokeWidth: 2 + fgColour: (device.bbmEntry?.ComputedLevel ?? 100) < 20 + ? Colours.palette.m3error + : root.colour + bgColour: Qt.alpha(root.colour, 0.2) + } + + MaterialIcon { + id: deviceIcon + anchors.centerIn: parent + animate: true + text: Icons.getBluetoothIcon(device.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 + } } } } diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index 7191948e1e..b944a3943b 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -38,49 +38,72 @@ StyledRect { } spacing: Tokens.spacing.small - // ── Per-bud battery row ─────────────────────────────────────────────── + // ── Circular per-component battery rings ────────────────────────────── - RowLayout { + Item { visible: (root.bbmData?.Battery1Level ?? 0) > 0 Layout.fillWidth: true - spacing: Tokens.spacing.medium - - Repeater { - model: root.bbmData ? [ - {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, - {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, - {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, - ].filter(b => b.level > 0) : [] - - delegate: RowLayout { - required property var modelData - - spacing: Tokens.spacing.extraSmall - - BbmIcon { - url: BbmService.batteryIconUrl(modelData.icon) - visible: url.length > 0 - size: 16 - colour: Colours.palette.m3onSurface - } + implicitHeight: batteryRow.implicitHeight + + Row { + id: batteryRow + anchors.horizontalCenter: parent.horizontalCenter + spacing: Tokens.spacing.large + + Repeater { + model: root.bbmData ? [ + {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, + {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, + {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, + ].filter(b => b.level > 0) : [] + + 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 + : circleDel.modelData.level < 20 ? Colours.palette.m3error + : Colours.palette.m3primary + bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) + } - StyledProgressBar { - implicitWidth: 32 - implicitHeight: 4 - value: modelData.level / 100 - fgColour: modelData.level < 20 ? Colours.palette.m3error : Colours.palette.m3primary - } + BbmIcon { + anchors.centerIn: parent + url: BbmService.batteryIconUrl(circleDel.modelData.icon) + visible: url.length > 0 + size: 22 + colour: circleDel.modelData.charging ? Colours.palette.m3tertiary + : circleDel.modelData.level < 20 ? Colours.palette.m3error + : Colours.palette.m3onSurface + } - StyledText { - text: `${modelData.level}%` - color: Colours.palette.m3onSurfaceVariant - font: Tokens.font.body.small - } + // 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 + } + } - MaterialIcon { - visible: modelData.charging - text: "bolt" - color: Colours.palette.m3primary + StyledText { + anchors.horizontalCenter: parent.horizontalCenter + text: `${circleDel.modelData.level}%` + color: Colours.palette.m3onSurfaceVariant + font: Tokens.font.body.small + } } } } @@ -250,9 +273,6 @@ StyledRect { } // ── Theme-aware SVG icon ────────────────────────────────────────────────── - // Mirrors ColouredIcon.qml: ImageAnalyser reads the dominant colour from - // the rendered image so Colouriser can compensate for any source luminosity - // dynamically — no hardcoded fill value, works across all BBM SVG variants. component BbmIcon: Item { id: iconItem From 7d2d441e30bba9918be76a37374fe94c48b7e44d Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:28:20 +0530 Subject: [PATCH 13/18] Add generic bluetooth battery controls and status fixes --- .envrc | 11 +- CMakeLists.txt | 4 + modules/bar/Bar.qml | 34 +- modules/bar/components/StatusIcons.qml | 2 + .../bar/components/status/BluetoothStatus.qml | 117 +++--- modules/bar/popouts/Bluetooth.qml | 85 +++- modules/nexus/pages/ServicesPage.qml | 377 +++++++++++++++++- plugin/src/Caelestia/Config/serviceconfig.hpp | 21 + 8 files changed, 577 insertions(+), 74 deletions(-) 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 88ffd35ad5..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) 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..e98b4df223 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -129,7 +129,9 @@ 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 9f403b0c3c..9c0e8263cc 100644 --- a/modules/bar/components/status/BluetoothStatus.qml +++ b/modules/bar/components/status/BluetoothStatus.qml @@ -13,8 +13,37 @@ 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 @@ -24,12 +53,11 @@ Item { } } - ColumnLayout { + RowLayout { id: layout - spacing: Tokens.spacing.medium / 2 + spacing: Tokens.spacing.extraSmall - // Bluetooth icon MaterialIcon { animate: true text: { @@ -42,67 +70,32 @@ Item { color: root.colour } - // Connected bluetooth devices — with optional BBM battery ring - 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 + } - Item { - id: device - - required property BluetoothDevice modelData - - // Cached once: avoids calling dataFor() three times in three separate bindings. - readonly property var bbmEntry: BbmService.available - ? BbmService.dataFor(modelData?.address ?? "") - : null - - // Ring adds strokeWidth on each side plus 1px breathing room per side. - implicitWidth: deviceIcon.implicitWidth + batteryRing.strokeWidth * 2 + 2 - implicitHeight: deviceIcon.implicitHeight + batteryRing.strokeWidth * 2 + 2 - - // Circular battery ring — only visible when BBM tracks this device - CircularProgress { - id: batteryRing - anchors.fill: parent - value: (device.bbmEntry?.ComputedLevel ?? 0) / 100 - visible: device.bbmEntry !== null - strokeWidth: 2 - fgColour: (device.bbmEntry?.ComputedLevel ?? 100) < 20 - ? Colours.palette.m3error - : root.colour - bgColour: Qt.alpha(root.colour, 0.2) - } - - MaterialIcon { - id: deviceIcon - anchors.centerIn: parent - animate: true - text: Icons.getBluetoothIcon(device.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/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 41d695ca30..321912705f 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -14,6 +14,35 @@ 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 palette = GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUseCustomColors + ? (GlobalConfig.services.bluetoothBatteryColors || []) + : [ + "#22c55e", + "#22c55e", + "#4ade80", + "#84cc16", + "#facc15", + "#f59e0b", + "#fb923c", + "#f97316", + "#f87171", + "#ef4444" + ]; + const clamp = Math.max(0, Math.min(100, Number(levelPercent) || 0)); + if (clamp >= 100) + return palette[0] || palette[0] || Colours.palette.m3primary; + const idx = Math.min(9, Math.max(0, 9 - Math.floor(clamp / 10))); + return palette[idx] || palette[0] || Colours.palette.m3primary; + } width: 320 spacing: Tokens.spacing.small @@ -101,9 +130,27 @@ ColumnLayout { spacing: Tokens.spacing.small MaterialIcon { + visible: true text: Icons.getBluetoothIcon(device.modelData.icon) } + 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 + } + + 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 + } + StyledText { Layout.leftMargin: Tokens.spacing.extraSmall Layout.rightMargin: Tokens.spacing.extraSmall @@ -112,10 +159,21 @@ ColumnLayout { elide: Text.ElideRight } + 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: device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + 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 && device.modelData.battery < 0.2 ? Colours.palette.m3error : Colours.palette.m3onSurfaceVariant + color: device.modelData.batteryAvailable + ? root.batteryColorForLevel(device.modelData.battery * 100) + : Colours.palette.m3error } StyledRect { @@ -179,12 +237,27 @@ ColumnLayout { Loader { visible: status === Loader.Ready - active: device.modelData.connected - && BbmService.available - && BbmService.dataFor(device.modelData.address) !== null + active: root.showBatteryRing && device.modelData.connected && ( + (BbmService.available && BbmService.dataFor(device.modelData.address) !== null) + || (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport && device.modelData.batteryAvailable) + ) Layout.fillWidth: true sourceComponent: BbmExpandedRow { - bbmData: BbmService.dataFor(device.modelData.address) + bbmData: { + const bbm = BbmService.available ? BbmService.dataFor(device.modelData.address) : null; + if (bbm) + return bbm; + if (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport && device.modelData.batteryAvailable) { + return { + Battery1Level: Math.round(device.modelData.battery * 100), + Battery1Icon: "", + Battery1Status: "discharging", + Battery2Level: 0, + Battery3Level: 0, + }; + } + return null; + } address: device.modelData.address } } diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml index 4595261769..b21fed5fa4 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,285 @@ 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/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) From e3a1be7833ae745f8c2fe3a129f5b7686195c90c Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:15:47 +0530 Subject: [PATCH 14/18] bbm: deduplicate helpers and constants across BBM QML files - Extract BbmIcon inline component to shared BbmIcon.qml - Add batteriesFor(), togglesFor() helpers to BbmService singleton - Add STATUS_CHARGING and WIDGET_ANC_LEVEL constants to BbmService - Replace hardcoded retryTimer interval with _retryIntervalMs constant - Fix _init() race: start monitor before listProc via Qt.callLater - Remove redundant batteryIconUrl visibility check in BtDeviceHover - batteryColorForLevel kept local in each file (Colours singleton not safely importable from within qs.services without circular dep risk) Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/popouts/BbmExpandedRow.qml | 76 ++---- modules/bar/popouts/BbmIcon.qml | 45 +++ modules/bar/popouts/Bluetooth.qml | 118 +++++--- modules/bar/popouts/BtDeviceHover.qml | 364 +++++++++++++++++++++++++ services/BbmService.qml | 83 +++++- 5 files changed, 587 insertions(+), 99 deletions(-) create mode 100644 modules/bar/popouts/BbmIcon.qml create mode 100644 modules/bar/popouts/BtDeviceHover.qml diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index b944a3943b..d7907e9063 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -15,6 +15,19 @@ StyledRect { 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 @@ -51,11 +64,7 @@ StyledRect { spacing: Tokens.spacing.large Repeater { - model: root.bbmData ? [ - {icon: root.bbmData.Battery1Icon ?? "", level: root.bbmData.Battery1Level ?? 0, charging: (root.bbmData.Battery1Status ?? "") === "charging"}, - {icon: root.bbmData.Battery2Icon ?? "", level: root.bbmData.Battery2Level ?? 0, charging: (root.bbmData.Battery2Status ?? "") === "charging"}, - {icon: root.bbmData.Battery3Icon ?? "", level: root.bbmData.Battery3Level ?? 0, charging: (root.bbmData.Battery3Status ?? "") === "charging"}, - ].filter(b => b.level > 0) : [] + model: BbmService.batteriesFor(root.bbmData) delegate: Column { id: circleDel @@ -73,8 +82,7 @@ StyledRect { value: circleDel.modelData.level / 100 strokeWidth: 4 fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary - : circleDel.modelData.level < 20 ? Colours.palette.m3error - : Colours.palette.m3primary + : root.batteryColorForLevel(circleDel.modelData.level) bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) } @@ -84,8 +92,7 @@ StyledRect { visible: url.length > 0 size: 22 colour: circleDel.modelData.charging ? Colours.palette.m3tertiary - : circleDel.modelData.level < 20 ? Colours.palette.m3error - : Colours.palette.m3onSurface + : root.batteryColorForLevel(circleDel.modelData.level) } // Charging bolt badge at bottom of ring @@ -112,10 +119,7 @@ StyledRect { // ── ANC / mode toggle (icon-only segmented control) ─────────────────── Repeater { - model: [ - {title: root.bbmData?.Toggle1Title ?? "", buttons: root.bbmData?.Toggle1Buttons ?? [], buttonIcons: root.bbmData?.Toggle1ButtonIcons ?? [], state: root.bbmData?.Toggle1State ?? 0, visible: root.bbmData?.Toggle1Visible ?? false, widgetId: "toggle1State"}, - {title: root.bbmData?.Toggle2Title ?? "", buttons: root.bbmData?.Toggle2Buttons ?? [], buttonIcons: root.bbmData?.Toggle2ButtonIcons ?? [], state: root.bbmData?.Toggle2State ?? 0, visible: root.bbmData?.Toggle2Visible ?? false, widgetId: "toggle2State"}, - ].filter(t => t.visible && t.buttons.length > 0) + model: BbmService.togglesFor(root.bbmData) delegate: ColumnLayout { id: toggleRow @@ -255,7 +259,7 @@ StyledRect { StateLayer { radius: Tokens.rounding.medium color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface - onClicked: BbmService.sendUIAction(root.address, "box1RadioButtonState", levelPill.modelData.index + 1) + onClicked: BbmService.sendUIAction(root.address, BbmService.WIDGET_ANC_LEVEL, levelPill.modelData.index + 1) } StyledText { @@ -272,48 +276,4 @@ StyledRect { } } - // ── Theme-aware SVG icon ────────────────────────────────────────────────── - - component BbmIcon: 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/BbmIcon.qml b/modules/bar/popouts/BbmIcon.qml new file mode 100644 index 0000000000..e2961d73ea --- /dev/null +++ b/modules/bar/popouts/BbmIcon.qml @@ -0,0 +1,45 @@ +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 321912705f..652884788e 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 @@ -23,25 +24,16 @@ ColumnLayout { readonly property bool showBatteryRing: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatteryRing) readonly property bool swapIconText: Boolean(GlobalConfig.services && GlobalConfig.services.bluetoothBatterySwapIconText) function batteryColorForLevel(levelPercent) { - const palette = GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUseCustomColors - ? (GlobalConfig.services.bluetoothBatteryColors || []) - : [ - "#22c55e", - "#22c55e", - "#4ade80", - "#84cc16", - "#facc15", - "#f59e0b", - "#fb923c", - "#f97316", - "#f87171", - "#ef4444" - ]; - const clamp = Math.max(0, Math.min(100, Number(levelPercent) || 0)); - if (clamp >= 100) - return palette[0] || palette[0] || Colours.palette.m3primary; - const idx = Math.min(9, Math.max(0, 9 - Math.floor(clamp / 10))); - return palette[idx] || palette[0] || Colours.palette.m3primary; + 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: 320 @@ -101,6 +93,16 @@ ColumnLayout { 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 @@ -129,21 +131,48 @@ ColumnLayout { Layout.fillWidth: true spacing: Tokens.spacing.small - MaterialIcon { - visible: true - text: Icons.getBluetoothIcon(device.modelData.icon) + // Device type icon (BBM SVG with Material fallback) + Item { + id: deviceListIconItem + + readonly property string bbmUrl: BbmService.deviceIconUrl(device.modelData.address, device.modelData.icon ?? "") + + implicitWidth: deviceListMat.implicitWidth + implicitHeight: deviceListMat.implicitHeight + + ColouredIcon { + anchors.centerIn: parent + source: deviceListIconItem.bbmUrl + visible: deviceListIconItem.bbmUrl.length > 0 + width: deviceListMat.implicitWidth + height: deviceListMat.implicitHeight + colour: Colours.palette.m3onSurface + } + + MaterialIcon { + id: deviceListMat + + 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 { - visible: root.showBatteryIcon && root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + 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 + 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) @@ -151,16 +180,32 @@ ColumnLayout { font: Tokens.font.body.small } - StyledText { - Layout.leftMargin: Tokens.spacing.extraSmall - Layout.rightMargin: Tokens.spacing.extraSmall + Item { Layout.fillWidth: true - text: device.modelData.name - elide: Text.ElideRight + implicitHeight: deviceName.implicitHeight + Tokens.padding.extraSmall + + StateLayer { + enabled: device.canExpand + radius: Tokens.rounding.small + onClicked: device.expanded = !device.expanded + } + + StyledText { + id: deviceName + 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 + } } StyledText { - visible: root.showBatteryText && !root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + 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) @@ -169,13 +214,15 @@ ColumnLayout { } MaterialIcon { - visible: root.showBatteryIcon && !root.swapIconText && device.modelData.state === BluetoothDeviceState.Connected // qmllint disable unresolved-type + 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 @@ -214,6 +261,7 @@ ColumnLayout { } } + // Forget button Loader { visible: status === Loader.Ready asynchronous: true @@ -237,17 +285,14 @@ ColumnLayout { Loader { visible: status === Loader.Ready - active: root.showBatteryRing && device.modelData.connected && ( - (BbmService.available && BbmService.dataFor(device.modelData.address) !== null) - || (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport && device.modelData.batteryAvailable) - ) + 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 (GlobalConfig.services && GlobalConfig.services.bluetoothBatteryUpowerSupport && device.modelData.batteryAvailable) { + if (device.modelData.batteryAvailable) { return { Battery1Level: Math.round(device.modelData.battery * 100), Battery1Icon: "", @@ -261,6 +306,7 @@ ColumnLayout { address: device.modelData.address } } + } } diff --git a/modules/bar/popouts/BtDeviceHover.qml b/modules/bar/popouts/BtDeviceHover.qml new file mode 100644 index 0000000000..f06a9d5dc6 --- /dev/null +++ b/modules/bar/popouts/BtDeviceHover.qml @@ -0,0 +1,364 @@ +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/services/BbmService.qml b/services/BbmService.qml index e303bd43db..c26a7e27bc 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -3,6 +3,7 @@ pragma Singleton import QtQuick import Quickshell import Quickshell.Io +import Caelestia.Config Singleton { id: root @@ -11,6 +12,10 @@ Singleton { 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 @@ -41,6 +46,69 @@ Singleton { 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, @@ -232,15 +300,20 @@ Singleton { function _init() { if (!monitor.running) monitor.running = true; - if (!listProc.running) - listProc.running = true; - if (!iconPathProc.running) - iconPathProc.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; + }); } Timer { id: retryTimer - interval: 5000 + interval: root._retryIntervalMs repeat: false onTriggered: root._init() } From 6ea996fe9dcaaad95d5a636e684aed464b4deecc Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:33:42 +0530 Subject: [PATCH 15/18] tests: add BBM unit tests (Node.js + QML) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/bbm/test-bbm-logic.mjs — 35 Node.js tests covering: - BbmService constants (STATUS_CHARGING, WIDGET_ANC_LEVEL, _retryIntervalMs) - batteriesFor(): null safety, level=0 filter, charging flag, all 3 slots, icon defaults, mixed levels, no mutation - togglesFor(): null safety, invisible/empty-button filter, widgetId per slot, field defaults, ordering - batteryColorForLevel(): lerp boundaries at 0/15/40/100, midpoints, NaN/negative/over-100 clamping, string input tests/bbm/tst_BbmIcon.qml — 8 Qt6 QML tests via qmltestrunner covering: - BbmIcon loads without Quickshell runtime (using stubs) - default size=18 drives implicitWidth/implicitHeight - size property is reactive - url and colour properties settable - empty url does not crash Run with: node tests/bbm/test-bbm-logic.mjs /usr/lib/qt6/bin/qmltestrunner \ -import tests/bbm/stubs -import tests/bbm \ -input tests/bbm/tst_BbmIcon.qml Co-Authored-By: Claude Sonnet 4.6 --- tests/bbm/qmldir | 2 + tests/bbm/stubs/BbmIcon.qml | 45 +++ .../qs/components/effects/Colouriser.qml | 6 + .../qs/components/effects/ImageAnalyser.qml | 7 + tests/bbm/stubs/qs/components/effects/qmldir | 3 + tests/bbm/test-bbm-logic.mjs | 340 ++++++++++++++++++ tests/bbm/tst_BbmIcon.qml | 65 ++++ 7 files changed, 468 insertions(+) create mode 100644 tests/bbm/qmldir create mode 100644 tests/bbm/stubs/BbmIcon.qml create mode 100644 tests/bbm/stubs/qs/components/effects/Colouriser.qml create mode 100644 tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml create mode 100644 tests/bbm/stubs/qs/components/effects/qmldir create mode 100644 tests/bbm/test-bbm-logic.mjs create mode 100644 tests/bbm/tst_BbmIcon.qml 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..e2961d73ea --- /dev/null +++ b/tests/bbm/stubs/BbmIcon.qml @@ -0,0 +1,45 @@ +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..d268de8ec0 --- /dev/null +++ b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml @@ -0,0 +1,7 @@ +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..0db8f94f0c --- /dev/null +++ b/tests/bbm/tst_BbmIcon.qml @@ -0,0 +1,65 @@ +import QtQuick +import QtTest + +// Points at the real BbmIcon.qml via the -import path supplied on the CLI. +// Stubs for qs.components.effects are provided from the stubs/ directory. + +TestCase { + id: root + name: "BbmIcon" + + // ── Component under test ───────────────────────────────────────────────── + // BbmIcon.qml required properties: + // required property string url + // required property color colour + // property int size: 18 (optional, default 18) + + Component { + id: bbmIconComp + BbmIcon { + url: "" + colour: "white" + } + } + + // ── Tests ──────────────────────────────────────────────────────────────── + + 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); + } +} From 748bc3ee8f7bf0625dd326fafeb3b3399c12e4b1 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:41:27 +0530 Subject: [PATCH 16/18] bbm: add BtDeviceDefaultsPage for per-device BT settings Lists all paired devices with BBM icon fallback and navigation to per-device settings subpage. Co-Authored-By: Claude Sonnet 4.6 --- .../pages/services/BtDeviceDefaultsPage.qml | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 modules/nexus/pages/services/BtDeviceDefaultsPage.qml diff --git a/modules/nexus/pages/services/BtDeviceDefaultsPage.qml b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml new file mode 100644 index 0000000000..1ac3d563e1 --- /dev/null +++ b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml @@ -0,0 +1,144 @@ +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 + } + } +} From 4512dc173e6c6b6a776a4eb435fa2a5b8e6a7474 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:45:01 +0530 Subject: [PATCH 17/18] style: qmlformat all BBM QML files Co-Authored-By: Claude Sonnet 4.6 --- .../bar/components/status/BluetoothStatus.qml | 10 +- modules/bar/popouts/BbmExpandedRow.qml | 65 ++++++------ modules/bar/popouts/Bluetooth.qml | 38 +++---- modules/bar/popouts/BtDeviceHover.qml | 99 +++++++++---------- modules/nexus/pages/ServicesPage.qml | 31 +++--- .../pages/services/BtDeviceDefaultsPage.qml | 11 +-- services/BbmService.qml | 85 +++++++++------- .../qs/components/effects/ImageAnalyser.qml | 3 +- tests/bbm/tst_BbmIcon.qml | 6 +- 9 files changed, 179 insertions(+), 169 deletions(-) diff --git a/modules/bar/components/status/BluetoothStatus.qml b/modules/bar/components/status/BluetoothStatus.qml index 9c0e8263cc..cd19a73721 100644 --- a/modules/bar/components/status/BluetoothStatus.qml +++ b/modules/bar/components/status/BluetoothStatus.qml @@ -33,12 +33,18 @@ Item { if (device.batteryAvailable !== undefined && device.batteryAvailable !== null) { const pct = Number(device.battery) * 100; if (Number.isFinite(pct)) - return { percentage: pct, available: true }; + 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 { + percentage: bbmLevel, + available: true + }; } return null; } diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index d7907e9063..f1d7e3bfe4 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -23,8 +23,10 @@ StyledRect { 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); + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); return lerp(tertiary, primary, (level - 40) / 60); } @@ -81,8 +83,7 @@ StyledRect { anchors.fill: parent value: circleDel.modelData.level / 100 strokeWidth: 4 - fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary - : root.batteryColorForLevel(circleDel.modelData.level) + fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) } @@ -91,14 +92,16 @@ StyledRect { url: BbmService.batteryIconUrl(circleDel.modelData.icon) visible: url.length > 0 size: 22 - colour: circleDel.modelData.charging ? Colours.palette.m3tertiary - : root.batteryColorForLevel(circleDel.modelData.level) + 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 } + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } text: "bolt" color: Colours.palette.m3tertiary fontStyle: Tokens.font.icon.small @@ -143,12 +146,12 @@ StyledRect { 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, - })) + name, + icon: (toggleRow.modelData.buttonIcons ?? [])[i] ?? "", + active: toggleRow.modelData.state === (i + 1), + widgetId: toggleRow.modelData.widgetId, + index: i + })) delegate: StyledRect { id: pill @@ -159,12 +162,12 @@ StyledRect { 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) + color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) Behavior on color { - Anim { type: Anim.DefaultEffects } + Anim { + type: Anim.DefaultEffects + } } StateLayer { @@ -192,8 +195,7 @@ StyledRect { Item { id: ancLevelContainer - readonly property bool showing: (root.bbmData?.OptionsBoxVisible ?? 0) === 1 - && (root.bbmData?.AncLevelButtons ?? []).length > 0 + readonly property bool showing: (root.bbmData?.OptionsBoxVisible ?? 0) === 1 && (root.bbmData?.AncLevelButtons ?? []).length > 0 Layout.fillWidth: true implicitHeight: showing ? ancLevelLayout.implicitHeight : 0 @@ -214,11 +216,15 @@ StyledRect { transformOrigin: Item.Top Behavior on opacity { - Anim { type: Anim.StandardSmall } + Anim { + type: Anim.StandardSmall + } } Behavior on scale { - Anim { type: Anim.StandardSmall } + Anim { + type: Anim.StandardSmall + } } StyledText { @@ -234,10 +240,10 @@ StyledRect { Repeater { model: (root.bbmData?.AncLevelButtons ?? []).map((name, i) => ({ - name, - active: (root.bbmData?.AncLevelState ?? 0) === (i + 1), - index: i, - })) + name, + active: (root.bbmData?.AncLevelState ?? 0) === (i + 1), + index: i + })) delegate: StyledRect { id: levelPill @@ -248,12 +254,12 @@ StyledRect { 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) + color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) Behavior on color { - Anim { type: Anim.DefaultEffects } + Anim { + type: Anim.DefaultEffects + } } StateLayer { @@ -275,5 +281,4 @@ StyledRect { } } } - } diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 652884788e..683f311acf 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -31,8 +31,10 @@ ColumnLayout { 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); + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); return lerp(tertiary, primary, (level - 40) / 60); } @@ -96,13 +98,10 @@ ColumnLayout { 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) - ) + 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 + onCanExpandChanged: if (!canExpand) + expanded = false Layout.fillWidth: true Layout.leftMargin: Tokens.padding.extraSmall @@ -156,27 +155,21 @@ ColumnLayout { 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 ?? "") + text: deviceListIconItem.bbmUrl.length > 0 ? "bluetooth" : Icons.getDeviceIcon(device.modelData.address, device.modelData.icon ?? "") } } 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 + 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 + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error font: Tokens.font.body.small } @@ -207,18 +200,14 @@ ColumnLayout { 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 + 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 + color: device.modelData.batteryAvailable ? root.batteryColorForLevel(device.modelData.battery * 100) : Colours.palette.m3error fontStyle: Tokens.font.icon.small } @@ -298,7 +287,7 @@ ColumnLayout { Battery1Icon: "", Battery1Status: "discharging", Battery2Level: 0, - Battery3Level: 0, + Battery3Level: 0 }; } return null; @@ -306,7 +295,6 @@ ColumnLayout { address: device.modelData.address } } - } } diff --git a/modules/bar/popouts/BtDeviceHover.qml b/modules/bar/popouts/BtDeviceHover.qml index f06a9d5dc6..c7bd549a23 100644 --- a/modules/bar/popouts/BtDeviceHover.qml +++ b/modules/bar/popouts/BtDeviceHover.qml @@ -28,8 +28,10 @@ StyledRect { 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); + if (level <= 15) + return error; + if (level <= 40) + return lerp(error, tertiary, (level - 15) / 25); return lerp(tertiary, primary, (level - 40) / 60); } @@ -62,9 +64,7 @@ StyledRect { 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 + readonly property var bbmData: BbmService.available ? BbmService.dataFor(deviceSection.address) : null Layout.fillWidth: true spacing: Tokens.spacing.small @@ -112,11 +112,13 @@ StyledRect { 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}] - : [] + values: deviceSection.bbmData ? BbmService.batteriesFor(deviceSection.bbmData) : deviceSection.modelData?.batteryAvailable === true ? [ + { + icon: "", + level: Math.round((deviceSection.modelData?.battery ?? 0) * 100), + charging: false + } + ] : [] } ColumnLayout { @@ -134,9 +136,7 @@ StyledRect { anchors.fill: parent value: circleDel.modelData.level / 100 strokeWidth: 4 - fgColour: circleDel.modelData.charging - ? Colours.palette.m3tertiary - : root.batteryColorForLevel(circleDel.modelData.level) + fgColour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) bgColour: Qt.alpha(Colours.palette.m3onSurface, 0.12) } @@ -145,9 +145,7 @@ StyledRect { url: BbmService.batteryIconUrl(circleDel.modelData.icon) visible: url.length > 0 size: 22 - colour: circleDel.modelData.charging - ? Colours.palette.m3tertiary - : root.batteryColorForLevel(circleDel.modelData.level) + colour: circleDel.modelData.charging ? Colours.palette.m3tertiary : root.batteryColorForLevel(circleDel.modelData.level) } MaterialIcon { @@ -159,7 +157,10 @@ StyledRect { MaterialIcon { visible: circleDel.modelData.charging - anchors { bottom: parent.bottom; horizontalCenter: parent.horizontalCenter } + anchors { + bottom: parent.bottom + horizontalCenter: parent.horizontalCenter + } text: "bolt" color: Colours.palette.m3tertiary fontStyle: Tokens.font.icon.small @@ -179,11 +180,8 @@ StyledRect { // ── 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)) + 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 @@ -192,9 +190,7 @@ StyledRect { // ── Toggle rows (Noise Control etc.) ────────────────────────── Repeater { - model: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) - ? BbmService.togglesFor(deviceSection.bbmData) - : [] + model: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) ? BbmService.togglesFor(deviceSection.bbmData) : [] delegate: ColumnLayout { id: toggleGroup @@ -217,13 +213,13 @@ StyledRect { 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, - })) + 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 @@ -235,12 +231,12 @@ StyledRect { 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) + color: pill.modelData.active ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3onSurface, 0.08) Behavior on color { - Anim { type: Anim.DefaultEffects } + Anim { + type: Anim.DefaultEffects + } } StateLayer { @@ -275,9 +271,7 @@ StyledRect { Item { id: ancLevelContainer - readonly property bool showing: (GlobalConfig.services?.bluetoothHoverShowControls ?? true) - && (deviceSection.bbmData?.OptionsBoxVisible ?? 0) === 1 - && (deviceSection.bbmData?.AncLevelButtons ?? []).length > 0 + 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 @@ -297,11 +291,15 @@ StyledRect { transformOrigin: Item.Top Behavior on opacity { - Anim { type: Anim.StandardSmall } + Anim { + type: Anim.StandardSmall + } } Behavior on scale { - Anim { type: Anim.StandardSmall } + Anim { + type: Anim.StandardSmall + } } StyledText { @@ -317,11 +315,11 @@ StyledRect { Repeater { model: (deviceSection.bbmData?.AncLevelButtons ?? []).map((name, i) => ({ - name, - active: (deviceSection.bbmData?.AncLevelState ?? 0) === (i + 1), - index: i, - addr: deviceSection.address, - })) + name, + active: (deviceSection.bbmData?.AncLevelState ?? 0) === (i + 1), + index: i, + addr: deviceSection.address + })) delegate: StyledRect { id: levelPill @@ -331,12 +329,12 @@ StyledRect { 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) + color: levelPill.modelData.active ? Colours.palette.m3secondary : Qt.alpha(Colours.palette.m3onSurface, 0.08) Behavior on color { - Anim { type: Anim.DefaultEffects } + Anim { + type: Anim.DefaultEffects + } } StateLayer { @@ -360,5 +358,4 @@ StyledRect { } } } - } diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml index b21fed5fa4..83421f990b 100644 --- a/modules/nexus/pages/ServicesPage.qml +++ b/modules/nexus/pages/ServicesPage.qml @@ -227,9 +227,7 @@ PageBase { menuItems: root.batteryIndicatorItems active: { const mode = GlobalConfig.services ? GlobalConfig.services.bluetoothBatteryIndicatorType : undefined; - const idx = Number.isFinite(mode) - ? root.batteryIndicatorValues.indexOf(mode) - : 2; + 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 => { @@ -274,21 +272,24 @@ PageBase { 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 + 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 + 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 + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatterySortByHistory = checked } SelectRow { @@ -331,14 +332,16 @@ PageBase { 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 + 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 + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryHoverMode = checked } StepperRow { @@ -358,28 +361,32 @@ PageBase { 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 + 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 + 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 + 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 + onToggled: if (GlobalConfig.services) + GlobalConfig.services.bluetoothBatteryUseCustomColors = checked } SelectRow { diff --git a/modules/nexus/pages/services/BtDeviceDefaultsPage.qml b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml index 1ac3d563e1..cdd4cc9020 100644 --- a/modules/nexus/pages/services/BtDeviceDefaultsPage.qml +++ b/modules/nexus/pages/services/BtDeviceDefaultsPage.qml @@ -34,8 +34,7 @@ PageBase { 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)) + .filter(d => d.bonded).sort((a, b) => (b.connected - a.connected) || a.name.localeCompare(b.name)) } delegate: ConnectedRect { @@ -44,9 +43,7 @@ PageBase { required property BluetoothDevice modelData // qmllint disable unresolved-type required property int index - readonly property string bbmUrl: BbmService.deviceIconUrl( - deviceRow.modelData.address, - deviceRow.modelData.icon ?? "") + readonly property string bbmUrl: BbmService.deviceIconUrl(deviceRow.modelData.address, deviceRow.modelData.icon ?? "") Layout.fillWidth: true implicitHeight: rowContent.implicitHeight + Tokens.padding.medium * 2 @@ -90,9 +87,7 @@ PageBase { 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 ?? "") + 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 diff --git a/services/BbmService.qml b/services/BbmService.qml index c26a7e27bc..f8261ef8f7 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -52,21 +52,11 @@ Singleton { 'phone-apple-iphone-symbolic': 'phone', 'phone-apple-iphone': 'phone', 'phone-google-nexus-one': 'phone', - 'phone-samsung-galaxy-s': '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', - ]; + 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; } @@ -93,27 +83,53 @@ Singleton { } function batteriesFor(bbmData) { - if (!bbmData) return []; + 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}, + { + 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 []; + 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"}, + { + 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)] + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.SendUIAction`, `'${address}'`, `'${widgetId}'`, String(value)] }).running = true; } @@ -171,9 +187,7 @@ Singleton { result[key] = []; } else if (raw.startsWith('[')) { const content = raw.slice(1, -1).trim(); - result[key] = content - ? content.split(/, (?=')/).map(item => item.replace(/^'|'$/g, '')) - : []; + 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); @@ -195,9 +209,7 @@ Singleton { id: fetchDeviceComp Process { property string addr: "" - command: ["gdbus", "call", "--session", "--dest", root._dest, - "--object-path", root._path, "--method", `${root._iface}.GetDeviceData`, - `'${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); @@ -224,8 +236,7 @@ Singleton { Process { id: iconPathProc running: false - command: ["gdbus", "call", "--session", "--dest", root._dest, - "--object-path", root._path, "--method", `${root._iface}.GetIconPath`] + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.GetIconPath`] stdout: StdioCollector { onStreamFinished: { // Output format: ('/path/to/icons',)\n @@ -244,8 +255,7 @@ Singleton { // 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] + command: ["stdbuf", "-oL", "gdbus", "monitor", "--session", "--dest", root._dest, "--object-path", root._path] stdout: SplitParser { onRead: line => { const ifaceEsc = root._iface.replace(/\./g, "\\."); @@ -273,8 +283,7 @@ Singleton { Process { id: listProc running: false - command: ["gdbus", "call", "--session", "--dest", root._dest, - "--object-path", root._path, "--method", `${root._iface}.GetDevices`] + command: ["gdbus", "call", "--session", "--dest", root._dest, "--object-path", root._path, "--method", `${root._iface}.GetDevices`] stdout: StdioCollector { onStreamFinished: { if (!text.trim().startsWith("(")) @@ -294,7 +303,9 @@ Singleton { // ── Internal helpers ────────────────────────────────────────────────────── function _fetchDevice(address) { - fetchDeviceComp.createObject(root, {addr: address}).running = true; + fetchDeviceComp.createObject(root, { + addr: address + }).running = true; } function _init() { diff --git a/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml index d268de8ec0..6b0e6991a6 100644 --- a/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml +++ b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml @@ -3,5 +3,6 @@ import QtQuick Item { property var sourceItem: null property color dominantColour: "transparent" - function requestUpdate() {} + function requestUpdate() { + } } diff --git a/tests/bbm/tst_BbmIcon.qml b/tests/bbm/tst_BbmIcon.qml index 0db8f94f0c..f2275903c2 100644 --- a/tests/bbm/tst_BbmIcon.qml +++ b/tests/bbm/tst_BbmIcon.qml @@ -17,7 +17,7 @@ TestCase { Component { id: bbmIconComp BbmIcon { - url: "" + url: "" colour: "white" } } @@ -32,7 +32,7 @@ TestCase { function test_default_size_18() { var obj = createTemporaryObject(bbmIconComp, root); compare(obj.size, 18); - compare(obj.implicitWidth, 18); + compare(obj.implicitWidth, 18); compare(obj.implicitHeight, 18); } @@ -40,7 +40,7 @@ TestCase { var obj = createTemporaryObject(bbmIconComp, root); obj.size = 32; compare(obj.size, 32); - compare(obj.implicitWidth, 32); + compare(obj.implicitWidth, 32); compare(obj.implicitHeight, 32); } From 97f624c1e15190008c6b4e5f20e396a409a8f2e8 Mon Sep 17 00:00:00 2001 From: SilentOne <155584784+silentone12725@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:52:21 +0530 Subject: [PATCH 18/18] style: fix qml-lint-conventions violations in BBM files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds blank separators between QML object sections and fixes section ordering to match the Qt coding conventions (id → properties → functions → bindings → child objects). Co-Authored-By: Claude Sonnet 4.6 --- modules/bar/components/StatusIcons.qml | 1 + modules/bar/popouts/BbmExpandedRow.qml | 3 ++ modules/bar/popouts/BbmIcon.qml | 2 + modules/bar/popouts/Bluetooth.qml | 2 + modules/bar/popouts/BtDeviceHover.qml | 4 ++ modules/nexus/pages/ServicesPage.qml | 2 + services/BbmService.qml | 53 ++++++++++--------- tests/bbm/stubs/BbmIcon.qml | 2 + .../qs/components/effects/ImageAnalyser.qml | 1 + tests/bbm/tst_BbmIcon.qml | 31 ++++------- 10 files changed, 57 insertions(+), 44 deletions(-) diff --git a/modules/bar/components/StatusIcons.qml b/modules/bar/components/StatusIcons.qml index e98b4df223..c3f5fd0aa4 100644 --- a/modules/bar/components/StatusIcons.qml +++ b/modules/bar/components/StatusIcons.qml @@ -130,6 +130,7 @@ StyledRect { roleValue: "bluetooth" delegate: EntryWrapper { name: "bluetooth" + BluetoothStatus { name: "bluetooth" colour: root.colour diff --git a/modules/bar/popouts/BbmExpandedRow.qml b/modules/bar/popouts/BbmExpandedRow.qml index f1d7e3bfe4..006dce3f43 100644 --- a/modules/bar/popouts/BbmExpandedRow.qml +++ b/modules/bar/popouts/BbmExpandedRow.qml @@ -62,6 +62,7 @@ StyledRect { Row { id: batteryRow + anchors.horizontalCenter: parent.horizontalCenter spacing: Tokens.spacing.large @@ -178,6 +179,7 @@ StyledRect { BbmIcon { id: pillIcon + anchors.centerIn: parent url: BbmService.toggleIconUrl(pill.modelData.icon) visible: url.length > 0 @@ -270,6 +272,7 @@ StyledRect { StyledText { id: levelLabel + anchors.centerIn: parent text: levelPill.modelData.name color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface diff --git a/modules/bar/popouts/BbmIcon.qml b/modules/bar/popouts/BbmIcon.qml index e2961d73ea..582d1530ec 100644 --- a/modules/bar/popouts/BbmIcon.qml +++ b/modules/bar/popouts/BbmIcon.qml @@ -13,6 +13,7 @@ Item { Image { id: img + anchors.fill: parent source: iconItem.url sourceSize: Qt.size(iconItem.size, iconItem.size) @@ -40,6 +41,7 @@ Item { ImageAnalyser { id: analyser + sourceItem: img } } diff --git a/modules/bar/popouts/Bluetooth.qml b/modules/bar/popouts/Bluetooth.qml index 683f311acf..0acc1cfed6 100644 --- a/modules/bar/popouts/Bluetooth.qml +++ b/modules/bar/popouts/Bluetooth.qml @@ -23,6 +23,7 @@ ColumnLayout { 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; @@ -185,6 +186,7 @@ ColumnLayout { StyledText { id: deviceName + anchors { left: parent.left right: parent.right diff --git a/modules/bar/popouts/BtDeviceHover.qml b/modules/bar/popouts/BtDeviceHover.qml index c7bd549a23..915796ae78 100644 --- a/modules/bar/popouts/BtDeviceHover.qml +++ b/modules/bar/popouts/BtDeviceHover.qml @@ -84,6 +84,7 @@ StyledRect { BbmIcon { readonly property string url_: BbmService.deviceIconUrl(deviceSection.address, deviceSection.modelData?.icon ?? "") + url: url_ visible: url_.length > 0 size: 16 @@ -182,6 +183,7 @@ StyledRect { 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 @@ -247,6 +249,7 @@ StyledRect { BbmIcon { id: pillIcon + anchors.centerIn: parent url: pill.iconUrl visible: url.length > 0 @@ -345,6 +348,7 @@ StyledRect { StyledText { id: levelLabel + anchors.centerIn: parent text: levelPill.modelData.name color: levelPill.modelData.active ? Colours.palette.m3onSecondary : Colours.palette.m3onSurface diff --git a/modules/nexus/pages/ServicesPage.qml b/modules/nexus/pages/ServicesPage.qml index 83421f990b..9342ef74ea 100644 --- a/modules/nexus/pages/ServicesPage.qml +++ b/modules/nexus/pages/ServicesPage.qml @@ -440,7 +440,9 @@ PageBase { ColorDialog { id: colorDialog + property int bucketIndex: 0 + title: qsTr("Choose battery colour") onAccepted: { if (!GlobalConfig.services) diff --git a/services/BbmService.qml b/services/BbmService.qml index f8261ef8f7..a16572d85b 100644 --- a/services/BbmService.qml +++ b/services/BbmService.qml @@ -133,6 +133,26 @@ Singleton { }).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) { @@ -202,13 +222,17 @@ Singleton { 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: { @@ -227,6 +251,7 @@ Singleton { // Dynamic: fire-and-forget SendUIAction, self-destroys. Component { id: sendActionComp + Process { onExited: destroy() } @@ -235,6 +260,7 @@ Singleton { // 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 { @@ -251,6 +277,7 @@ Singleton { 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 @@ -282,6 +309,7 @@ Singleton { // 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 { @@ -300,34 +328,11 @@ Singleton { } } - // ── Internal helpers ────────────────────────────────────────────────────── - - 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; - }); - } - Timer { id: retryTimer + interval: root._retryIntervalMs repeat: false onTriggered: root._init() } - - Component.onCompleted: _init() } diff --git a/tests/bbm/stubs/BbmIcon.qml b/tests/bbm/stubs/BbmIcon.qml index e2961d73ea..582d1530ec 100644 --- a/tests/bbm/stubs/BbmIcon.qml +++ b/tests/bbm/stubs/BbmIcon.qml @@ -13,6 +13,7 @@ Item { Image { id: img + anchors.fill: parent source: iconItem.url sourceSize: Qt.size(iconItem.size, iconItem.size) @@ -40,6 +41,7 @@ Item { ImageAnalyser { id: analyser + sourceItem: img } } diff --git a/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml index 6b0e6991a6..10318d9234 100644 --- a/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml +++ b/tests/bbm/stubs/qs/components/effects/ImageAnalyser.qml @@ -3,6 +3,7 @@ import QtQuick Item { property var sourceItem: null property color dominantColour: "transparent" + function requestUpdate() { } } diff --git a/tests/bbm/tst_BbmIcon.qml b/tests/bbm/tst_BbmIcon.qml index f2275903c2..3211e341fb 100644 --- a/tests/bbm/tst_BbmIcon.qml +++ b/tests/bbm/tst_BbmIcon.qml @@ -1,28 +1,8 @@ import QtQuick import QtTest -// Points at the real BbmIcon.qml via the -import path supplied on the CLI. -// Stubs for qs.components.effects are provided from the stubs/ directory. - TestCase { id: root - name: "BbmIcon" - - // ── Component under test ───────────────────────────────────────────────── - // BbmIcon.qml required properties: - // required property string url - // required property color colour - // property int size: 18 (optional, default 18) - - Component { - id: bbmIconComp - BbmIcon { - url: "" - colour: "white" - } - } - - // ── Tests ──────────────────────────────────────────────────────────────── function test_instantiates() { var obj = createTemporaryObject(bbmIconComp, root); @@ -62,4 +42,15 @@ TestCase { obj.url = ""; verify(obj !== null); } + + name: "BbmIcon" + + Component { + id: bbmIconComp + + BbmIcon { + url: "" + colour: "white" + } + } }