feat: mqtt audio volume control #82

This commit is contained in:
leukipp
2025-09-26 13:47:15 +02:00
parent 890c84d69d
commit 552f0e19a4
2 changed files with 196 additions and 37 deletions
+152 -37
View File
@@ -28,6 +28,9 @@ global.HARDWARE = global.HARDWARE || {
keyboard: { keyboard: {
visible: null, visible: null,
}, },
audio: {
device: null,
},
}; };
/** /**
@@ -50,6 +53,7 @@ const init = async () => {
HARDWARE.display.status.command = getDisplayStatusCommand(); HARDWARE.display.status.command = getDisplayStatusCommand();
HARDWARE.display.brightness.path = getDisplayBrightnessPath(); HARDWARE.display.brightness.path = getDisplayBrightnessPath();
HARDWARE.display.brightness.max = getDisplayBrightnessMax(); HARDWARE.display.brightness.max = getDisplayBrightnessMax();
HARDWARE.audio.device = getAudioDevice();
HARDWARE.support = checkSupport(); HARDWARE.support = checkSupport();
HARDWARE.initialized = true; HARDWARE.initialized = true;
@@ -91,25 +95,45 @@ const init = async () => {
`Display Brightness [${HARDWARE.display.brightness.path || unsupported}]:`, `Display Brightness [${HARDWARE.display.brightness.path || unsupported}]:`,
`${getDisplayBrightness() || unsupported}`, `${getDisplayBrightness() || unsupported}`,
); );
console.log(
`Audio Volume [${HARDWARE.support.audioVolume ? HARDWARE.audio.device : unsupported}]:`,
`${getAudioVolume() || unsupported}`,
);
console.log( console.log(
`Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "squeekboard" : unsupported}]:`, `Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "squeekboard" : unsupported}]:`,
`${getKeyboardVisibility() || unsupported}`, `${getKeyboardVisibility() || unsupported}`,
); );
// Check for keyboard visibility
process.stdout.write("\n"); process.stdout.write("\n");
setKeyboardVisibility("OFF", (reply, error) => {
if (!reply || error) { // Monitor audio volume
return; if (HARDWARE.support.audioVolume) {
} commandMonitor("pactl", ["subscribe"], (reply, error) => {
dbusMonitor("/sm/puri/OSK0", (property, error) => { if (!reply || error) {
if (!property || error) {
return; return;
} }
HARDWARE.keyboard.visibility = property.Visible === "true"; if (reply.includes("'change' on sink")) {
EVENTS.emit("updateKeyboard"); console.log("Update Audio Volume:", getAudioVolume());
EVENTS.emit("updateVolume");
}
}); });
}); }
// Monitor keyboard visibility
if (HARDWARE.support.keyboardVisibility) {
setKeyboardVisibility("OFF", (reply, error) => {
if (!reply || error) {
return;
}
dbusMonitor("/sm/puri/OSK0", (property, error) => {
if (!property || error) {
return;
}
HARDWARE.keyboard.visibility = property.Visible === "true";
console.log("Update Keyboard Visibility:", getKeyboardVisibility());
EVENTS.emit("updateKeyboard");
});
});
}
// Check for display changes // Check for display changes
setDisplayStatus("ON", () => { setDisplayStatus("ON", () => {
@@ -209,6 +233,7 @@ const checkSupport = () => {
const battery = HARDWARE.battery.level; const battery = HARDWARE.battery.level;
const status = HARDWARE.display.status; const status = HARDWARE.display.status;
const brightness = HARDWARE.display.brightness; const brightness = HARDWARE.display.brightness;
const audio = HARDWARE.audio.device;
const keyboard = processRuns("squeekboard"); const keyboard = processRuns("squeekboard");
const service = serviceRuns(APP.name); const service = serviceRuns(APP.name);
const sudo = sudoRights(); const sudo = sudoRights();
@@ -218,8 +243,9 @@ const checkSupport = () => {
displayStatus: !!status.path && !!status.command, displayStatus: !!status.path && !!status.command,
displayBrightness: sudo && !!brightness.path && !!brightness.max, displayBrightness: sudo && !!brightness.path && !!brightness.max,
keyboardVisibility: keyboard, keyboardVisibility: keyboard,
audioVolume: !!audio,
sudoRights: sudo, sudoRights: sudo,
appUpdate: sudo && service && deb, appUpdate: service && sudo && deb,
}; };
}; };
@@ -483,7 +509,7 @@ const getDisplayStatus = () => {
case "xset": case "xset":
const xset = execSyncCommand("xset", ["-q"]); const xset = execSyncCommand("xset", ["-q"]);
if (xset !== null) { if (xset !== null) {
const output = /Monitor is On/.test(xset); const output = xset.includes("Monitor is On");
return output ? "ON" : "OFF"; return output ? "ON" : "OFF";
} }
break; break;
@@ -599,6 +625,65 @@ const setDisplayBrightness = (brightness, callback = null) => {
proc.stdin.end(); proc.stdin.end();
}; };
/**
* Gets the default audio device using `pactl`.
*
* @returns {string|null} The default audio device or null if an error occurs.
*/
const getAudioDevice = () => {
if (!commandExists("pactl")) {
return null;
}
const output = execSyncCommand("pactl", ["get-default-sink"]);
if (!output) {
return null;
}
return !output.includes("auto_null") ? output.trim() : null;
};
/**
* Gets the default audio device volume using `pactl`.
*
* @returns {number|null} The default audio device volume as a percentage or null if an error occurs.
*/
const getAudioVolume = () => {
if (!HARDWARE.support.audioVolume) {
return null;
}
const mute = execSyncCommand("pactl", ["get-sink-mute", "@DEFAULT_SINK@"]);
const volume = execSyncCommand("pactl", ["get-sink-volume", "@DEFAULT_SINK@"]);
if (!mute || !volume) {
return null;
}
const match = volume.match(/\/(\s*(\d+)%)/);
if (match) {
return Math.max(0, Math.min(100, mute.includes("yes") ? 0 : parseInt(match[2])));
}
return null;
};
/**
* Sets the default audio device volume using `pactl`.
*
* This function takes a volume value between 0 to 100 percent and sends it to the device.
*
* @param {number} volume - The desired volume level (0-100).
* @param {Function} callback - A callback function that receives the output or error.
*/
const setAudioVolume = (volume, callback = null) => {
if (!HARDWARE.support.audioVolume) {
if (typeof callback === "function") callback(null, "Not supported");
return;
}
if (typeof volume !== "number" || volume < 0 || volume > 100) {
console.error("Volume must be a number between 0 and 100");
if (typeof callback === "function") callback(null, "Invalid volume");
return;
}
execAsyncCommand("pactl", ["set-sink-mute", "@DEFAULT_SINK@", volume === 0 ? "1" : "0"]);
execAsyncCommand("pactl", ["set-sink-volume", "@DEFAULT_SINK@", `${volume}%`], callback);
};
/** /**
* Gets the keyboard visibility using global properties. * Gets the keyboard visibility using global properties.
* *
@@ -689,8 +774,8 @@ const rebootSystem = (callback = null) => {
*/ */
const sudoRights = () => { const sudoRights = () => {
try { try {
const output = cpr.execSync(`sudo -n true`, { encoding: "utf8" }); cpr.execSync(`sudo -n true`, { encoding: "utf8", stdio: "ignore" });
return output !== null && !output.includes("password is required"); return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -703,7 +788,8 @@ const sudoRights = () => {
*/ */
const serviceRuns = (name) => { const serviceRuns = (name) => {
try { try {
return !!cpr.execSync(`systemctl --user is-active ${name}`, { encoding: "utf8" }); cpr.execSync(`systemctl --user is-active ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -716,7 +802,8 @@ const serviceRuns = (name) => {
*/ */
const processRuns = (name) => { const processRuns = (name) => {
try { try {
return !!cpr.execSync(`pidof ${name}`, { encoding: "utf8" }); cpr.execSync(`pidof ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -729,7 +816,8 @@ const processRuns = (name) => {
*/ */
const commandExists = (name) => { const commandExists = (name) => {
try { try {
return !!cpr.execSync(`which ${name}`, { encoding: "utf8" }); cpr.execSync(`which ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -741,7 +829,7 @@ const commandExists = (name) => {
* @param {Array<string>} args - The arguments for the command. * @param {Array<string>} args - The arguments for the command.
* @returns {string|null} The output of the command or null if an error occurs. * @returns {string|null} The output of the command or null if an error occurs.
*/ */
const execSyncCommand = (cmd, args = []) => { const execSyncCommand = (cmd, args) => {
try { try {
const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" }); const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" });
return output.trim().replace(/\0/g, ""); return output.trim().replace(/\0/g, "");
@@ -759,7 +847,7 @@ const execSyncCommand = (cmd, args = []) => {
* @param {Function} callback - A callback function that receives the output or error. * @param {Function} callback - A callback function that receives the output or error.
* @returns {Object} The spawned process object. * @returns {Object} The spawned process object.
*/ */
const execAsyncCommand = (cmd, args = [], callback = null) => { const execAsyncCommand = (cmd, args, callback = null) => {
let errorOutput = ""; let errorOutput = "";
let successOutput = ""; let successOutput = "";
let proc = cpr.spawn(cmd, args); let proc = cpr.spawn(cmd, args);
@@ -794,7 +882,7 @@ const execAsyncCommand = (cmd, args = [], callback = null) => {
* @param {Function} callback - A callback function that receives the progress or error. * @param {Function} callback - A callback function that receives the progress or error.
* @returns {Object} The spawned process object. * @returns {Object} The spawned process object.
*/ */
const execScriptCommand = (cmd, args = [], callback = null) => { const execScriptCommand = (cmd, args, callback = null) => {
let progress = 1; let progress = 1;
let proc = cpr.spawn(cmd, args); let proc = cpr.spawn(cmd, args);
if (typeof callback === "function") callback(progress, null); if (typeof callback === "function") callback(progress, null);
@@ -836,25 +924,28 @@ const execScriptCommand = (cmd, args = [], callback = null) => {
}; };
/** /**
* Executes a D-Bus method call synchronously using `dbus-send`. * Monitors a command asynchronously.
* *
* @param {string} path - The D-Bus object path. * @param {string} cmd - The command to monitor.
* @param {string} method - The D-Bus method name. * @param {Array<string>} args - The arguments for the command.
* @param {Array<string>} values - The argument values for the D-Bus method.
* @param {Function} callback - A callback function that receives the output or error. * @param {Function} callback - A callback function that receives the output or error.
* @returns {Object} The spawned process object.
*/ */
const dbusCall = (path, method, values, callback = null) => { const commandMonitor = (cmd, args, callback) => {
const cmd = "dbus-send"; const proc = cpr.spawn(cmd, args);
const iface = path.slice(1).replace(/\//g, "."); proc.stdout.on("data", (data) => {
const dest = `${iface} ${path} ${iface}.${method} ${values.join(" ")}`; if (data) {
const args = ["--print-reply", "--type=method_call", `--dest=${dest}`]; const output = data.toString().trim().replace(/\0/g, "");
try { if (typeof callback === "function") callback(output, null);
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" }); }
if (typeof callback === "function") callback(output.trim().replace(/\0/g, ""), null); });
} catch (error) { proc.stderr.on("data", (data) => {
console.error("Call D-Bus:", error.message); if (data) {
if (typeof callback === "function") callback(null, error.message); const output = data.toString().trim().replace(/\0/g, "");
} if (typeof callback === "function") callback(null, output);
}
});
return proc;
}; };
/** /**
@@ -899,6 +990,28 @@ const dbusMonitor = (path, callback) => {
return proc; return proc;
}; };
/**
* Executes a D-Bus method call synchronously using `dbus-send`.
*
* @param {string} path - The D-Bus object path.
* @param {string} method - The D-Bus method name.
* @param {Array<string>} values - The argument values for the D-Bus method.
* @param {Function} callback - A callback function that receives the output or error.
*/
const dbusCall = (path, method, values, callback = null) => {
const cmd = "dbus-send";
const iface = path.slice(1).replace(/\//g, ".");
const dest = `${iface} ${path} ${iface}.${method} ${values.join(" ")}`;
const args = ["--print-reply", "--type=method_call", `--dest=${dest}`];
try {
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" });
if (typeof callback === "function") callback(output.trim().replace(/\0/g, ""), null);
} catch (error) {
console.error("Call D-Bus:", error.message);
if (typeof callback === "function") callback(null, error.message);
}
};
/** /**
* Helper function for asynchronous interval calls. * Helper function for asynchronous interval calls.
* *
@@ -938,6 +1051,8 @@ module.exports = {
setDisplayStatus, setDisplayStatus,
getDisplayBrightness, getDisplayBrightness,
setDisplayBrightness, setDisplayBrightness,
getAudioVolume,
setAudioVolume,
getKeyboardVisibility, getKeyboardVisibility,
setKeyboardVisibility, setKeyboardVisibility,
checkPackageUpgrades, checkPackageUpgrades,
+44
View File
@@ -68,6 +68,7 @@ const init = async () => {
initRefresh(); initRefresh();
initKiosk(); initKiosk();
initDisplay(); initDisplay();
initVolume();
initKeyboard(); initKeyboard();
initPageNumber(); initPageNumber();
initPageZoom(); initPageZoom();
@@ -97,6 +98,7 @@ const init = async () => {
// Update sensor states from events // Update sensor states from events
EVENTS.on("updateDisplay", updateDisplay); EVENTS.on("updateDisplay", updateDisplay);
EVENTS.on("updateVolume", updateVolume);
EVENTS.on("updateKeyboard", updateKeyboard); EVENTS.on("updateKeyboard", updateKeyboard);
// Update time sensors periodically (30s) // Update time sensors periodically (30s)
@@ -462,6 +464,48 @@ const updateDisplay = async () => {
publishState("display/brightness", brightness); publishState("display/brightness", brightness);
}; };
/**
* Initializes the audio volume and handles the execute logic.
*/
const initVolume = () => {
const root = `${INTEGRATION.root}/volume`;
const config = {
name: "Volume",
unique_id: `${INTEGRATION.node}_volume`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value | int }}",
mode: "slider",
min: 0,
max: 100,
unit_of_measurement: "%",
icon: "mdi:volume-high",
device: INTEGRATION.device,
};
if (!HARDWARE.support.audioVolume) {
removeConfig("number", config);
return;
}
publishConfig("number", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const volume = parseInt(message, 10);
console.log("Set Audio Volume:", volume);
hardware.setAudioVolume(volume);
}
})
.subscribe(config.command_topic);
updateVolume();
};
/**
* Updates the audio volume via the mqtt connection.
*/
const updateVolume = async () => {
const volume = hardware.getAudioVolume();
publishState("volume", volume);
};
/** /**
* Initializes the keyboard visibility and handles the execute logic. * Initializes the keyboard visibility and handles the execute logic.
*/ */