diff options
Diffstat (limited to 'desktop/island')
| -rw-r--r-- | desktop/island/HeadphonesPanel.qml | 508 | ||||
| -rw-r--r-- | desktop/island/Island.qml | 10 | ||||
| -rw-r--r-- | desktop/island/README.md | 20 | ||||
| -rw-r--r-- | desktop/island/StatusPill.qml | 41 | ||||
| -rwxr-xr-x | desktop/island/sonyctl | 178 | ||||
| -rw-r--r-- | desktop/island/test_sonyctl.py | 55 |
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") |
