aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/island/HeadphonesPanel.qml508
-rw-r--r--desktop/island/Island.qml10
-rw-r--r--desktop/island/README.md20
-rw-r--r--desktop/island/StatusPill.qml41
-rwxr-xr-xdesktop/island/sonyctl178
-rw-r--r--desktop/island/test_sonyctl.py55
6 files changed, 804 insertions, 8 deletions
diff --git a/desktop/island/HeadphonesPanel.qml b/desktop/island/HeadphonesPanel.qml
new file mode 100644
index 0000000..86e3ae4
--- /dev/null
+++ b/desktop/island/HeadphonesPanel.qml
@@ -0,0 +1,508 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Widgets
+import Quickshell.Services.Pipewire
+import QtQuick
+import ".."
+import "Logic.js" as Logic
+
+// The Sony headphones: battery, noise control tiles and ambient level
+// through sonyctl, the headphones' own volume, power off and refresh.
+// Battery is BlueZ's and volume PipeWire's; only the mode needs the Sony
+// protocol. Glyphs are Material Design codepoints checked against the
+// Inconsolata Nerd Font cmap (AGENTS.md).
+Column {
+ id: panel
+
+ required property var device // the connected BluetoothDevice
+ property bool open: false
+ signal close()
+
+ readonly property string sonyctl: Qt.resolvedUrl("sonyctl").toString().replace(/^file:\/\//, "")
+ property string mode: "" // "nc", "ambient", "off"; "" until read
+ property int level: 10
+ property bool voice: false
+ property bool busy: false
+ property string error: ""
+
+ // The headphones' sink rather than the default one, so the slider is
+ // theirs even while another output is selected.
+ readonly property PwNode sink: {
+ const prefix = "bluez_output." + (device?.address ?? "").replace(/:/g, "_");
+ return Pipewire.nodes.values.find(n => n.name.startsWith(prefix)) ?? null;
+ }
+ readonly property var audio: sink?.audio ?? null
+ PwObjectTracker { objects: panel.sink ? [panel.sink] : [] }
+
+ readonly property real battery: device?.batteryAvailable ? device.battery : -1
+
+ function glyph(cp) { return String.fromCodePoint(cp); }
+
+ // Read on every open: the headphones' own button changes the mode too,
+ // and nothing pushes it here.
+ onOpenChanged: if (open) run(["status"])
+
+ // One run at a time: each opens its own RFCOMM link, and a second one
+ // while the first is up is refused by the headphones. A fresh Process per
+ // run (a running one ignores another start, AGENTS.md).
+ function run(args) {
+ if (busy || !device) return;
+ busy = true;
+ op.createObject(panel, { command: [sonyctl, device.address].concat(args) }).running = true;
+ }
+
+ Component {
+ id: op
+ Process {
+ id: proc
+ stdout: StdioCollector {
+ onStreamFinished: {
+ let r;
+ try { r = JSON.parse(text); }
+ catch (e) { r = { ok: false, error: "sonyctl: " + (text.trim() || "no output") }; }
+ panel.error = r.ok ? "" : r.error;
+ if (r.ok && r.mode) { panel.mode = r.mode; panel.level = r.level; panel.voice = r.voice; }
+ panel.busy = false;
+ proc.destroy();
+ }
+ }
+ }
+ }
+
+ // The ambient level is sent once the slider or wheel settles: each set
+ // is a ~2s round trip, and a send per wheel notch would queue up.
+ property int pendingLevel: -1
+ readonly property int shownLevel: pendingLevel > 0 ? pendingLevel : level
+ Timer {
+ id: levelSend
+ interval: 500
+ onTriggered: {
+ const v = panel.pendingLevel;
+ if (panel.busy) { restart(); return; }
+ panel.pendingLevel = -1;
+ if (v > 0 && v !== panel.level) { panel.level = v; panel.run(["ambient", String(v)]); }
+ }
+ }
+
+ spacing: 14
+
+ // --- header ----------------------------------------------------------
+
+ Item {
+ width: panel.width
+ height: 26
+
+ Text {
+ id: headGlyph
+ anchors.verticalCenter: parent.verticalCenter
+ text: panel.glyph(0xF02CB) // md-headphones
+ font { family: Theme.iconFamily; pixelSize: 22 }
+ color: Theme.accent
+ }
+
+ Rectangle {
+ id: headSep
+ anchors { left: headGlyph.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ width: 1
+ height: 18
+ color: Qt.alpha(Theme.text, 0.15)
+ }
+
+ Text {
+ anchors {
+ left: headSep.right; leftMargin: 10; right: closeGlyph.left; rightMargin: 8
+ verticalCenter: parent.verticalCenter
+ }
+ elide: Text.ElideRight
+ text: (panel.device?.deviceName || panel.device?.name || "Headphones").toUpperCase()
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; letterSpacing: 2 }
+ color: Theme.text
+ }
+
+ Text {
+ id: closeGlyph
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: panel.glyph(0xF0156) // md-close
+ font { family: Theme.iconFamily; pixelSize: 18 }
+ color: closeHit.containsMouse ? Theme.text : Theme.subtext
+
+ MouseArea {
+ id: closeHit
+ anchors { fill: parent; margins: -4 }
+ hoverEnabled: true
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.close()
+ }
+ }
+ }
+
+ // --- battery ---------------------------------------------------------
+
+ Row {
+ visible: panel.battery >= 0
+ width: panel.width
+ spacing: 10
+
+ Text {
+ id: batGlyph
+ anchors.verticalCenter: parent.verticalCenter
+ // md-battery_10 .. md-battery_90 are consecutive, md-battery is full.
+ text: panel.glyph(panel.battery >= 0.95 ? 0xF0079
+ : 0xF007A + Math.max(0, Math.min(8, Math.round(panel.battery * 10) - 1)))
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: batBar.tint
+ }
+
+ Rectangle {
+ id: batBar
+ readonly property color tint: panel.battery < 0.2 ? Theme.red : Theme.accent
+ anchors.verticalCenter: parent.verticalCenter
+ width: parent.width - batGlyph.width - batPct.width - parent.spacing * 2
+ height: 8
+ radius: 4
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ width: parent.width * Math.max(0, panel.battery)
+ color: batBar.tint
+ }
+ }
+
+ Text {
+ id: batPct
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ horizontalAlignment: Text.AlignRight
+ text: Math.round(panel.battery * 100) + "%"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 1; bold: true }
+ color: batBar.tint
+ }
+ }
+
+ // --- noise control ---------------------------------------------------
+
+ Text {
+ text: "NOISE CONTROL"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 5; bold: true; letterSpacing: 1.5 }
+ color: Theme.accent
+ }
+
+ Row {
+ id: tiles
+ spacing: 8
+
+ Repeater {
+ model: [
+ { id: "nc", label: "ANC", cp: 0xF147D }, // md-waveform
+ { id: "ambient", label: "Ambient", cp: 0xF05CB }, // md-account_voice
+ { id: "off", label: "Off", cp: 0xF073A } // md-cancel
+ ]
+
+ delegate: Rectangle {
+ id: tile
+ required property var modelData
+ readonly property bool current: panel.mode === modelData.id
+ width: (panel.width - tiles.spacing * 2) / 3
+ height: 84
+ radius: 12
+ color: current ? Qt.alpha(Theme.accent, 0.14)
+ : tileHit.containsMouse ? Qt.alpha(Theme.surface, 0.8) : Qt.alpha(Theme.surface, 0.4)
+ border.width: current ? 1.5 : 1
+ border.color: current ? Theme.accent : Qt.alpha(Theme.text, 0.1)
+ opacity: panel.busy && !current ? 0.6 : 1
+
+ Column {
+ anchors.centerIn: parent
+ spacing: 6
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: panel.glyph(tile.modelData.cp)
+ font { family: Theme.iconFamily; pixelSize: 26 }
+ color: tile.current ? Theme.accent : Theme.text
+ }
+
+ Text {
+ anchors.horizontalCenter: parent.horizontalCenter
+ text: tile.modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 3 }
+ color: tile.current ? Theme.text : Theme.subtext
+ }
+
+ Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: 6
+ height: 6
+ radius: 3
+ color: tile.current ? Theme.accent : Theme.overlay
+ }
+ }
+
+ MouseArea {
+ id: tileHit
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: !panel.busy && !tile.current
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.run([tile.modelData.id])
+ }
+ }
+ }
+ }
+
+ // --- sliders ---------------------------------------------------------
+
+ // Ambient level 1-20, while in ambient mode. Wheel or drag, sent once it
+ // settles (levelSend above).
+ Slider {
+ visible: panel.mode === "ambient"
+ cp: 0xF07C5 // md-ear_hearing
+ value: panel.shownLevel / 20
+ label: String(panel.shownLevel)
+ onMoved: v => { panel.pendingLevel = Math.max(1, Math.min(20, Math.round(v * 20))); levelSend.restart(); }
+ onStepped: d => {
+ panel.pendingLevel = Math.max(1, Math.min(20, panel.shownLevel + d));
+ levelSend.restart();
+ }
+ }
+
+ // Focus on voice, an ambient-only flag: the headphones keep it in the
+ // other modes but it does nothing there.
+ Item {
+ visible: panel.mode === "ambient"
+ width: panel.width
+ height: voiceSwitch.height
+
+ Text {
+ id: voiceGlyph
+ anchors.verticalCenter: parent.verticalCenter
+ text: panel.glyph(0xF1308) // md-account_tie_voice
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: Theme.subtext
+ }
+
+ Text {
+ anchors { left: voiceGlyph.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: "Focus on voice"
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+
+ Switch {
+ id: voiceSwitch
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ enabled: !panel.busy
+ checked: panel.voice
+ // The click flips checked itself, which breaks the binding;
+ // restore it so the switch follows what the headphones report.
+ onToggled: {
+ panel.run(["voice", checked ? "on" : "off"]);
+ checked = Qt.binding(() => panel.voice);
+ }
+ }
+ }
+
+ // The headphones' volume; the icon mutes, the wheel steps 5%.
+ Slider {
+ enabled: panel.audio !== null
+ icon: Logic.volumeIcon(panel.audio?.volume ?? 0, panel.audio?.muted ?? false)
+ value: panel.audio?.volume ?? 0
+ muted: panel.audio?.muted ?? false
+ label: !panel.audio ? "—" : panel.audio.muted ? "muted" : Math.round(panel.audio.volume * 100) + "%"
+ onIconClicked: panel.audio.muted = !panel.audio.muted
+ onMoved: v => panel.audio.volume = v
+ onStepped: d => panel.audio.volume = Math.max(0, Math.min(1, panel.audio.volume + d * 0.05))
+ }
+
+ Text {
+ visible: panel.error !== ""
+ width: panel.width
+ wrapMode: Text.Wrap
+ text: panel.error
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Theme.red
+ }
+
+ // --- footer ----------------------------------------------------------
+
+ Rectangle {
+ width: panel.width
+ height: 1
+ color: Qt.alpha(Theme.text, 0.1)
+ }
+
+ Row {
+ id: footer
+ width: panel.width
+
+ Repeater {
+ model: [
+ { label: "Power off", cp: 0xF0425, tint: "red", args: ["poweroff"] }, // md-power
+ { label: "Refresh", cp: 0xF0450, tint: "text", args: ["status"] } // md-refresh
+ ]
+
+ delegate: Item {
+ id: action
+ required property var modelData
+ required property int index
+ width: footer.width / 2
+ height: 36
+
+ Rectangle {
+ id: actionBox
+ anchors { left: parent.left; leftMargin: action.index ? 12 : 0; verticalCenter: parent.verticalCenter }
+ width: 36
+ height: 36
+ radius: 10
+ color: actionHit.containsMouse ? Qt.alpha(Theme[action.modelData.tint], 0.18)
+ : Qt.alpha(Theme.surface, 0.6)
+ border.width: 1
+ border.color: Qt.alpha(Theme[action.modelData.tint], 0.25)
+
+ Text {
+ id: actionGlyph
+ anchors.centerIn: parent
+ text: panel.glyph(action.modelData.cp)
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: Theme[action.modelData.tint]
+ // The refresh glyph turns while sonyctl runs.
+ RotationAnimation on rotation {
+ running: panel.busy && action.index === 1
+ from: 0; to: 360; duration: 900; loops: Animation.Infinite
+ onStopped: actionGlyph.rotation = 0
+ }
+ }
+ }
+
+ Text {
+ anchors { left: actionBox.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: action.modelData.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: Theme.text
+ }
+
+ Rectangle {
+ visible: action.index === 1
+ anchors { left: parent.left; verticalCenter: parent.verticalCenter }
+ width: 1
+ height: 22
+ color: Qt.alpha(Theme.text, 0.15)
+ }
+
+ MouseArea {
+ id: actionHit
+ anchors.fill: parent
+ hoverEnabled: true
+ enabled: !panel.busy
+ cursorShape: Qt.PointingHandCursor
+ onClicked: panel.run(action.modelData.args)
+ }
+ }
+ }
+ }
+
+ // One slider row: a glyph (cp) or a named icon (icon), a track, a label.
+ // moved(v) gives 0..1 from a click or drag; stepped(+1|-1) one wheel notch.
+ component Slider: Row {
+ id: row
+ property int cp: 0
+ property string icon: ""
+ property real value: 0
+ property bool muted: false
+ property string label: ""
+ signal moved(real v)
+ signal stepped(int d)
+ signal iconClicked()
+
+ width: panel.width
+ spacing: 10
+
+ Item {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 20
+ height: 20
+
+ Text {
+ visible: row.icon === ""
+ anchors.centerIn: parent
+ text: panel.glyph(row.cp)
+ font { family: Theme.iconFamily; pixelSize: 20 }
+ color: Theme.subtext
+ }
+
+ IconImage {
+ visible: row.icon !== ""
+ anchors.centerIn: parent
+ implicitSize: 18
+ source: row.icon !== "" ? Quickshell.iconPath(row.icon, true) : ""
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ enabled: row.enabled
+ cursorShape: Qt.PointingHandCursor
+ onClicked: row.iconClicked()
+ }
+ }
+
+ Rectangle {
+ anchors.verticalCenter: parent.verticalCenter
+ width: row.width - 20 - 44 - row.spacing * 2
+ height: 8
+ radius: 4
+ color: Theme.surface
+
+ Rectangle {
+ height: parent.height
+ radius: parent.radius
+ width: parent.width * Math.max(0, Math.min(1, row.value))
+ color: row.muted ? Theme.red : Theme.accent
+ opacity: row.muted ? 0.5 : 1
+ }
+
+ // Taller than the track so it is easy to hit. A MouseArea for the
+ // wheel, not a WheelHandler (AGENTS.md); one step per notch (120),
+ // since a touchpad sends many small deltas.
+ MouseArea {
+ anchors { fill: parent; topMargin: -8; bottomMargin: -8 }
+ enabled: row.enabled
+ preventStealing: true
+ cursorShape: Qt.PointingHandCursor
+ property real acc: 0
+ function at(x) { return Math.max(0, Math.min(1, x / width)); }
+ onPressed: mouse => row.moved(at(mouse.x))
+ onPositionChanged: mouse => { if (pressed) row.moved(at(mouse.x)); }
+ onWheel: wheel => {
+ acc += wheel.angleDelta.y;
+ while (Math.abs(acc) >= 120) {
+ row.stepped(acc > 0 ? 1 : -1);
+ acc -= acc > 0 ? 120 : -120;
+ }
+ wheel.accepted = true;
+ }
+ }
+ }
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ width: 44
+ horizontalAlignment: Text.AlignRight
+ text: row.label
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 }
+ color: row.muted ? Theme.red : Theme.subtext
+ }
+ }
+}
diff --git a/desktop/island/Island.qml b/desktop/island/Island.qml
index 754a3d0..d5abf57 100644
--- a/desktop/island/Island.qml
+++ b/desktop/island/Island.qml
@@ -42,7 +42,7 @@ PanelWindow {
WlrLayershell.namespace: "keystone-island"
WlrLayershell.keyboardFocus: panel !== "" ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
- // "", "sound", "calendar" or "obs".
+ // "", "sound", "headphones", "calendar" or "obs".
property string panel: ""
// --- reveal and hide -------------------------------------------------
@@ -136,6 +136,12 @@ PanelWindow {
function onConnectedChanged() { if (!Obs.connected && win.panel === "obs") win.panel = ""; }
}
+ // The same for the headphones disconnecting, or powered off from their panel.
+ Connections {
+ target: status
+ function onPhonesChanged() { if (!status.phones && win.panel === "headphones") win.panel = ""; }
+ }
+
HyprlandFocusGrab {
windows: [win]
active: win.panel !== ""
@@ -314,6 +320,8 @@ PanelWindow {
panel: win.panel
onMenuShown: win.menuHeld = true
onOpenSound: win.panel = win.panel === "sound" ? "" : "sound"
+ onOpenHeadphones: win.panel = win.panel === "headphones" ? "" : "headphones"
+ onClosePanel: win.panel = ""
onOpenDrawer: { win.panel = ""; win.drawer.show("sound"); }
}
diff --git a/desktop/island/README.md b/desktop/island/README.md
index 9c33f41..9353f7e 100644
--- a/desktop/island/README.md
+++ b/desktop/island/README.md
@@ -47,10 +47,22 @@ is open or not. Click opens the calendar panel.
## Status pill
-Left to right: volume icon, tray, layout flag. The performance panel that
-used to open from here is the drawer's Performance page now
-(`desktop/modules/perf/`).
-
+Left to right: headphones icon, volume icon, tray, layout flag. The
+performance panel that used to open from here is the drawer's Performance
+page now (`desktop/modules/perf/`).
+
+- **Headphones icon** shows while Sony headphones are connected (a BlueZ
+ name starting `WH-`, `WF-` or `LinkBuds`). Click opens the headphones
+ panel: battery (BlueZ), noise control tiles (ANC, ambient, off), the
+ ambient level (1-20, drag or wheel, sent once it settles) and focus on
+ voice (both shown in ambient mode only), the headphones'
+ own sink volume (their `bluez_output` node, not the default sink; wheel
+ steps 5%, the icon mutes), power off and refresh. The mode goes
+ through `sonyctl`, which speaks Sony's v2 protocol over RFCOMM channel 18
+ and is read on every open, since the headphones' button changes it too.
+ Each call is about 2s, so the panel takes one at a time. Test:
+ `/usr/bin/python3 desktop/island/test_sonyctl.py` (codec only, no
+ Bluetooth).
- **Volume icon** shows the default sink (muted, low, medium, high). Click
opens the sound panel.
- **Tray** shows `SystemTray` items. Left click activates, right click opens
diff --git a/desktop/island/StatusPill.qml b/desktop/island/StatusPill.qml
index 2202747..915807b 100644
--- a/desktop/island/StatusPill.qml
+++ b/desktop/island/StatusPill.qml
@@ -12,11 +12,13 @@
import Quickshell
import Quickshell.Widgets
import Quickshell.Services.SystemTray
+import Quickshell.Bluetooth
import QtQuick
import ".."
import "Logic.js" as Logic
-// Volume icon, tray, layout flag. The volume icon opens its panel in this
+// Headphones icon (while Sony headphones are connected), volume icon, tray,
+// layout flag. The headphones and volume icons open their panels in this
// capsule; the tray is a plain tray; the flag switches layout on scroll and
// ignores clicks.
Capsule {
@@ -27,15 +29,22 @@ Capsule {
required property QtObject window
property string panel: ""
+ // ponytail: Sony headphones are recognised by their model prefix; sonyctl
+ // speaks only the v2 protocol, measured on a WH-CH720N.
+ readonly property var phones: (Bluetooth.devices?.values ?? [])
+ .find(d => d.connected && /^(WH|WF)-|^LinkBuds/.test(d.deviceName)) ?? null
+
signal openSound()
+ signal openHeadphones()
+ signal closePanel()
signal openDrawer()
// Emitted just before a tray menu opens; the menu cannot report its close.
signal menuShown()
- expanded: panel === "sound"
+ expanded: panel === "sound" || panel === "headphones"
restWidth: restRow.implicitWidth + 24
panelWidth: 320
- panelHeight: soundPanel.implicitHeight + 28
+ panelHeight: (panel === "headphones" ? phonesPanel.implicitHeight : soundPanel.implicitHeight) + 28
Row {
@@ -47,6 +56,19 @@ Capsule {
Behavior on opacity { NumberAnimation { duration: 120 } }
IconImage {
+ visible: pill.phones !== null
+ anchors.verticalCenter: parent.verticalCenter
+ implicitSize: 18
+ source: Quickshell.iconPath("audio-headphones", true)
+
+ MouseArea {
+ anchors.fill: parent
+ cursorShape: Qt.PointingHandCursor
+ onClicked: pill.openHeadphones()
+ }
+ }
+
+ IconImage {
anchors.verticalCenter: parent.verticalCenter
implicitSize: 18
source: Quickshell.iconPath(Logic.volumeIcon(pill.sound.volume, pill.sound.muted), true)
@@ -122,4 +144,17 @@ Capsule {
Behavior on opacity { NumberAnimation { duration: 160 } }
onOpenDrawer: pill.openDrawer()
}
+
+ HeadphonesPanel {
+ id: phonesPanel
+ x: 14
+ y: 14
+ width: pill.panelWidth - 28
+ device: pill.phones
+ open: pill.panel === "headphones"
+ onClose: pill.closePanel()
+ opacity: open ? 1 : 0
+ visible: opacity > 0
+ Behavior on opacity { NumberAnimation { duration: 160 } }
+ }
}
diff --git a/desktop/island/sonyctl b/desktop/island/sonyctl
new file mode 100755
index 0000000..71b71d2
--- /dev/null
+++ b/desktop/island/sonyctl
@@ -0,0 +1,178 @@
+#!/usr/bin/python3
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The headphones panel's backend: noise cancelling on Sony headphones that
+# speak the "v2" MDR protocol (the WH-CH720N here) over RFCOMM. Volume and
+# battery are not here: volume is AVRCP through PipeWire, and battery is
+# BlueZ's Battery1, both of which the island already reads.
+#
+# sonyctl MAC status {ok, mode, level, voice}
+# sonyctl MAC nc|off set the mode, print the new state
+# sonyctl MAC ambient [LEVEL] ambient sound, LEVEL 1-20 (default: kept)
+# sonyctl MAC voice on|off ambient sound with focus on voice on or off
+#
+# Every set keeps the level and focus on voice it does not change.
+# sonyctl MAC poweroff turn the headphones off
+#
+# mode is "nc", "ambient" or "off". Errors print {ok: false, error}.
+#
+# Frames, as measured on a WH-CH720N: 3e, type, seq, 4-byte big-endian
+# length, payload, 1-byte sum of everything after 3e, 3c. 3c/3d/3e inside a
+# frame are sent as 3d then the byte & 0xef. Type 0c is a command, 01 an
+# ack; each side acks with seq 1 - the received seq, and the next command
+# uses the seq of the last ack received. The headphones answer nothing until
+# an init (00 00) has been sent on the connection.
+#
+# The ambient payload subtype is 17 on this model. 15, which Gadgetbridge
+# sends for headphones without wind-noise reduction, is acked and never
+# answered. The state is 17, 01, on, ambient, focus-on-voice, level.
+
+import errno
+import json
+import socket
+import sys
+import time
+
+CMD, ACK = 0x0C, 0x01
+# ponytail: the SDP record measured on a WH-CH720N puts the MDR service
+# (956c7b26-...) on channel 18. Look it up over SDP if another model differs.
+CHANNEL = 18
+SPECIAL = (0x3C, 0x3D, 0x3E)
+
+
+def encode(kind, seq, payload):
+ body = bytes([kind, seq]) + len(payload).to_bytes(4, "big") + bytes(payload)
+ body += bytes([sum(body) & 0xFF])
+ out = bytearray([0x3E])
+ for b in body:
+ out += bytes([0x3D, b & 0xEF]) if b in SPECIAL else bytes([b])
+ return bytes(out) + b"\x3c"
+
+
+def decode(raw):
+ """One frame, without its 3c trailer, to (type, seq, payload) or None."""
+ if not raw.startswith(b"\x3e"):
+ return None
+ body, i = bytearray(), 1
+ while i < len(raw):
+ if raw[i] == 0x3D and i + 1 < len(raw):
+ i += 1
+ body.append(raw[i] | 0x10)
+ else:
+ body.append(raw[i])
+ i += 1
+ if len(body) < 7 or sum(body[:-1]) & 0xFF != body[-1]:
+ return None
+ n = int.from_bytes(body[2:6], "big")
+ if n != len(body) - 7:
+ return None
+ return body[0], body[1], bytes(body[6:-1])
+
+
+def state(p):
+ """A 67/69 17 payload to {mode, level, voice}."""
+ mode = "off" if p[3] == 0 else ("ambient" if p[4] else "nc")
+ return {"mode": mode, "level": p[6], "voice": p[5] == 1}
+
+
+class Link:
+ def __init__(self, mac):
+ # A connect right after the previous run closed fails with EBUSY
+ # (measured) while the old channel is still going down.
+ for attempt in range(10):
+ self.s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)
+ self.s.settimeout(3)
+ try:
+ self.s.connect((mac, CHANNEL))
+ break
+ except OSError as e:
+ self.s.close()
+ if e.errno != errno.EBUSY or attempt == 9:
+ raise
+ time.sleep(0.3)
+ self.seq = 0
+ self.buf = b""
+
+ def frames(self):
+ while True:
+ while b"\x3c" in self.buf:
+ raw, self.buf = self.buf.split(b"\x3c", 1)
+ f = decode(raw[raw.find(b"\x3e"):]) if b"\x3e" in raw else None
+ if f:
+ yield f
+ chunk = self.s.recv(1024)
+ if not chunk:
+ raise ConnectionError("headphones closed the connection")
+ self.buf += chunk
+
+ def request(self, payload, reply):
+ """Send a command and return the first command payload starting with
+ one of the reply codes, acking everything that arrives meanwhile."""
+ self.s.send(encode(CMD, self.seq, payload))
+ for kind, seq, p in self.frames():
+ if kind == ACK:
+ self.seq = seq
+ continue
+ self.s.send(encode(ACK, 1 - seq, b""))
+ if p and p[0] in reply:
+ return p
+
+
+def run(mac, cmd, arg):
+ link = Link(mac)
+ try:
+ link.request([0x00, 0x00], (0x01,))
+ if cmd == "poweroff":
+ # Unanswered: the headphones go away instead. The ack is enough.
+ link.s.send(encode(CMD, link.seq, [0x24, 0x03, 0x01]))
+ for kind, _, _ in link.frames():
+ if kind == ACK:
+ return {"ok": True}
+ cur = state(link.request([0x66, 0x17], (0x67,)))
+ if cmd == "status":
+ return {"ok": True, **cur}
+ level, voice = cur["level"], int(cur["voice"])
+ if cmd == "voice":
+ voice = arg
+ elif arg is not None:
+ level = arg
+ on = 0 if cmd == "off" else 1
+ amb = 1 if cmd in ("ambient", "voice") else 0
+ p = link.request([0x68, 0x17, 0x01, on, amb, voice, level], (0x69,))
+ return {"ok": True, **state(p)}
+ finally:
+ link.s.close()
+
+
+def main(argv):
+ if len(argv) < 3 or argv[2] not in ("status", "nc", "ambient", "voice", "off", "poweroff"):
+ return {"ok": False, "error": "usage: sonyctl MAC status|nc|ambient [LEVEL]|voice on|off|off|poweroff"}
+ arg = None
+ if argv[2] == "voice":
+ if len(argv) < 4 or argv[3] not in ("on", "off"):
+ return {"ok": False, "error": "voice takes on or off"}
+ arg = int(argv[3] == "on")
+ if argv[2] == "ambient" and len(argv) > 3:
+ if not argv[3].isdigit() or not 1 <= int(argv[3]) <= 20:
+ return {"ok": False, "error": "level must be 1-20"}
+ arg = int(argv[3])
+ try:
+ return run(argv[1], argv[2], arg)
+ except (OSError, ConnectionError) as e:
+ return {"ok": False, "error": str(e) or type(e).__name__}
+
+
+if __name__ == "__main__":
+ r = main(sys.argv)
+ print(json.dumps(r))
+ sys.exit(0 if r["ok"] else 1)
diff --git a/desktop/island/test_sonyctl.py b/desktop/island/test_sonyctl.py
new file mode 100644
index 0000000..347e565
--- /dev/null
+++ b/desktop/island/test_sonyctl.py
@@ -0,0 +1,55 @@
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+"""Run: /usr/bin/python3 desktop/island/test_sonyctl.py
+
+The frame codec and state parsing only; never touches Bluetooth. The bytes
+are ones captured from a WH-CH720N.
+"""
+
+import sys
+sys.dont_write_bytecode = True # importing sonyctl must not leave a __pycache__
+
+import importlib.machinery
+import importlib.util
+from pathlib import Path
+
+path = str(Path(__file__).resolve().parent / "sonyctl")
+loader = importlib.machinery.SourceFileLoader("sonyctl", path)
+spec = importlib.util.spec_from_loader("sonyctl", loader)
+s = importlib.util.module_from_spec(spec)
+loader.exec_module(s)
+
+# Init request as sent, and the CH720N's init reply as received.
+assert s.encode(0x0C, 0, [0x00, 0x00]) == bytes.fromhex("3e0c000000000200000e3c")
+reply = s.encode(0x0C, 0, bytes.fromhex("0100030010020000"))
+assert s.decode(reply[:-1]) == (0x0C, 0, bytes.fromhex("0100030010020000"))
+
+# Escaping: a 3c, 3d or 3e in the body round-trips and never appears raw.
+for b in (0x3C, 0x3D, 0x3E):
+ f = s.encode(0x0C, 1, [0x68, b])
+ assert 0x3C not in f[1:-1] and 0x3E not in f[1:-1]
+ assert s.decode(f[:-1]) == (0x0C, 1, bytes([0x68, b]))
+
+# A bad checksum and a short frame are rejected, not misread.
+bad = bytearray(reply[:-1]); bad[-1] ^= 1
+assert s.decode(bytes(bad)) is None
+assert s.decode(b"\x3e\x0c") is None
+
+assert s.state(bytes.fromhex("6717010100000c")) == {"mode": "nc", "level": 12, "voice": False}
+assert s.state(bytes.fromhex("67170101010010")) == {"mode": "ambient", "level": 16, "voice": False}
+assert s.state(bytes.fromhex("6717010101010c")) == {"mode": "ambient", "level": 12, "voice": True}
+assert s.state(bytes.fromhex("69170100000010")) == {"mode": "off", "level": 16, "voice": False}
+
+assert not s.main(["sonyctl", "00:00:00:00:00:00", "ambient", "21"])["ok"]
+assert not s.main(["sonyctl"])["ok"]
+assert not s.main(["sonyctl", "00:00:00:00:00:00", "voice", "maybe"])["ok"]
+print("ok")