diff --git a/src/hardware.js b/src/hardware.js index f399820..3bcef1a 100644 --- a/src/hardware.js +++ b/src/hardware.js @@ -19,6 +19,10 @@ global.HARDWARE = global.HARDWARE || { }, notifiers: [], }, + keyboard: { + visible: null, + notifiers: [], + }, }; /** @@ -69,6 +73,23 @@ const init = async (args) => { console.log(`\nDisplay Status [${HARDWARE.display.status.path}]:`, getDisplayStatus()); console.log(`Display Brightness [${HARDWARE.display.brightness.path}]:`, getDisplayBrightness(), "\n"); + // Check for keyboard visibility changes + HARDWARE.support.keyboardVisibility = processRuns("squeekboard"); + setKeyboardVisibility("OFF", (reply, error) => { + if (!reply || error) { + return; + } + dbusMonitor("/sm/puri/OSK0", (property, error) => { + if (!property || error) { + return; + } + HARDWARE.keyboard.visibility = property.Visible === "true"; + HARDWARE.keyboard.notifiers.forEach((notifier) => { + notifier(); + }); + }); + }); + // Check for display changes setInterval(update, 500); @@ -124,9 +145,12 @@ const compatibleDevice = () => { /** * Queries the session type for the logged in user. * - * @returns {string} Returns session type x11/wayland or null if an error occurs. + * @returns {string} Returns session type 'x11'/'wayland' or null if an error occurs. */ const sessionType = () => { + if (!commandExists("loginctl")) { + return null; + } return execSyncCommand("loginctl", [ "show-session", "$(loginctl show-user $(whoami) -p Display --value)", @@ -146,7 +170,7 @@ const session = (type) => { /** * Gets the model name using `/sys/firmware/devicetree/base/model` or `/sys/class/dmi/id/product_name`. * - * @returns {string|null} The model name of the device or "Generic" if not found. + * @returns {string|null} The model name of the device or 'Generic' if not found. */ const getModel = () => { const paths = ["/sys/firmware/devicetree/base/model", "/sys/class/dmi/id/product_name"]; @@ -161,7 +185,7 @@ const getModel = () => { /** * Gets the vendor name using `/sys/class/dmi/id/board_vendor`. * - * @returns {string|null} The vendor name of the device or "Generic" if not found. + * @returns {string|null} The vendor name of the device or 'Generic' if not found. */ const getVendor = () => { const paths = ["/sys/class/dmi/id/board_vendor"]; @@ -180,7 +204,7 @@ const getVendor = () => { /** * Gets the serial number using `/sys/firmware/devicetree/base/serial-number` or `/sys/class/dmi/id/product_serial`. * - * @returns {string|null} The serial number of the device or "123456" if not found. + * @returns {string|null} The serial number of the device or '123456' if not found. */ const getSerialNumber = () => { const paths = ["/sys/firmware/devicetree/base/serial-number", "/sys/class/dmi/id/product_serial"]; @@ -195,7 +219,7 @@ const getSerialNumber = () => { /** * Gets the host machine id using `/etc/machine-id`. * - * @returns {string} The machine id of the system or "123456" if not found. + * @returns {string} The machine id of the system or '123456' if not found. */ const getMachineId = () => { return execSyncCommand("cat", ["/etc/machine-id"]) || "123456"; @@ -290,7 +314,7 @@ const getDisplayStatusPath = () => { /** * Gets the current display power status using `wlopm` or `xset`. * - * @returns {string|null} The display status ON/OFF or null if an error occurs. + * @returns {string|null} The display status as 'ON'/'OFF' or null if an error occurs. */ const getDisplayStatus = () => { if (!HARDWARE.support.displayStatus) { @@ -323,6 +347,7 @@ const getDisplayStatus = () => { */ const setDisplayStatus = (status, callback = null) => { if (!HARDWARE.support.displayStatus) { + if (typeof callback === "function") callback(null, "Not supported"); return; } if (status !== "ON" && status !== "OFF") { @@ -398,6 +423,7 @@ const getDisplayBrightness = () => { */ const setDisplayBrightness = (brightness, callback = null) => { if (!HARDWARE.support.displayBrightness) { + if (typeof callback === "function") callback(null, "Not supported"); return; } if (typeof brightness !== "number" || brightness < 1 || brightness > 100) { @@ -412,6 +438,52 @@ const setDisplayBrightness = (brightness, callback = null) => { proc.stdin.end(); }; +/** + * Gets the keyboard visibility using global properties. + * + * @returns {string|null} The keyboard visibility as 'ON'/'OFF' or null if an error occurs. + */ +const getKeyboardVisibility = () => { + if (!HARDWARE.support.keyboardVisibility) { + return null; + } + return HARDWARE.keyboard.visibility ? "ON" : "OFF"; +}; + +/** + * Sets the keyboard visibility using `dbus-send`. + * + * This function takes a desired visibility ('ON' or 'OFF') and executes + * the appropriate command to show or hide the keyboard. + * + * @param {boolean} visibility - The desired visibility ('ON' or 'OFF'). + * @param {function} callback - A callback function that receives the output or error. + */ +const setKeyboardVisibility = (visibility, callback = null) => { + if (!HARDWARE.support.keyboardVisibility) { + if (typeof callback === "function") callback(null, "Not supported"); + return; + } + const visible = visibility === "ON"; + HARDWARE.keyboard.visibility = visible; + dbusCall("/sm/puri/OSK0", "SetVisible", [`boolean:${visible}`], callback); +}; + +/** + * Checks if system upgrades are available using `apt`. + * + * @returns {Array} A list of package names that are available for upgrade. + */ +const checkPackageUpgrades = () => { + if (!commandExists("apt")) { + return []; + } + const output = execSyncCommand("apt", ["list", "--upgradable", "2>/dev/null"]); + const packages = (output || "").trim().split("\n"); + packages.shift(); + return packages; +}; + /** * Shuts down the system using `sudo shutdown -h now`. * @@ -437,14 +509,27 @@ const rebootSystem = (callback = null) => { }; /** - * Checks if a command exists using `which`. + * Checks if a process is running using `pidof`. * - * @param {string} cmd - The command name to check. - * @returns {boolean} Returns true if the command exists. + * @param {string} name - The process name to check. + * @returns {boolean} Returns true if the process runs. */ -const commandExists = (cmd) => { +const processRuns = (name) => { try { - return !!process.execSync(`which ${cmd}`, { encoding: "utf8" }); + return !!process.execSync(`pidof ${name}`, { encoding: "utf8" }); + } catch {} + return false; +}; + +/** + * Checks if a command is available using `which`. + * + * @param {string} name - The command name to check. + * @returns {boolean} Returns true if the command is available. + */ +const commandExists = (name) => { + try { + return !!process.execSync(`which ${name}`, { encoding: "utf8" }); } catch {} return false; }; @@ -499,6 +584,81 @@ const execAsyncCommand = (cmd, args = [], callback = null) => { } } catch (error) { console.error("Execute Async:", error.message); + if (typeof callback === "function") { + callback(null, error.message); + } + } + }); + 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} 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 = process.execSync([cmd, ...args].join(" "), { 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); + } + } +}; + +/** + * Monitors D-Bus property changes asynchronously using `dbus-monitor`. + * + * @param {string} path - The D-Bus object path. + * @param {function} callback - A callback function that receives the changed property. + * @returns {object} The spawned process object. + */ +const dbusMonitor = (path, callback) => { + const cmd = "dbus-monitor"; + const args = [`interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='${path}'`]; + const proc = process.spawn(cmd, args); + proc.stdout.on("data", (data) => { + try { + const signal = data.toString(); + if (signal.includes("member=PropertiesChanged") && signal.includes(`path=${path}`)) { + const dicts = [...signal.matchAll(/dict entry\(\s*([^)]*?)\)/g)].map((dict) => dict[1].trim()); + if (dicts.length) { + dicts.forEach((dict) => { + const key = dict.match(/string "(.*?)"/); + const value = dict.match(/(?<=variant\s+)(.*)/); + if (key && value && typeof callback === "function") { + callback({ [key[1].trim()]: value[1].trim().split(" ").pop() }, null); + } + }); + } else { + callback({ Visible: `${HARDWARE.keyboard.visibility}` }, null); + } + } + } catch (error) { + console.error("Monitor D-Bus:", error.message); + if (typeof callback === "function") { + callback(null, error.message); + } + } + }); + proc.stderr.on("data", (data) => { + if (data) { + console.error("Monitor D-Bus:", data.toString()); + if (typeof callback === "function") { + callback(null, data.toString()); + } } }); return proc; @@ -524,6 +684,9 @@ module.exports = { getDisplayBrightnessMax, getDisplayBrightness, setDisplayBrightness, + getKeyboardVisibility, + setKeyboardVisibility, + checkPackageUpgrades, shutdownSystem, rebootSystem, }; diff --git a/src/integration.js b/src/integration.js index 7cd0b41..0af36b1 100644 --- a/src/integration.js +++ b/src/integration.js @@ -37,15 +37,15 @@ const init = async (args) => { INTEGRATION.device = { name: `TouchKio ${deviceName}`, model: model, - serial_number: serialNumber, manufacturer: vendor, + serial_number: serialNumber, identifiers: [INTEGRATION.node], sw_version: `${app.getName()}-v${app.getVersion()}`, }; // Connection settings - const masked = password === null ? "null" : "*".repeat(password.length); const options = user === null || password == null ? null : { username: user, password: password }; + const masked = password === null ? "null" : "*".repeat(password.length); console.log("MQTT Connecting:", `${user}:${masked}@${url.toString()}`); INTEGRATION.client = mqtt.connect(url.toString(), options); @@ -60,6 +60,7 @@ const init = async (args) => { initRefresh(INTEGRATION.client); initKiosk(INTEGRATION.client); initDisplay(INTEGRATION.client); + initKeyboard(INTEGRATION.client); // Init client sensors initModel(INTEGRATION.client); @@ -70,7 +71,9 @@ const init = async (args) => { initMemoryUsage(INTEGRATION.client); initProcessorUsage(INTEGRATION.client); initProcessorTemperature(INTEGRATION.client); + initPackageUpgrades(INTEGRATION.client); initHeartbeat(INTEGRATION.client); + initLastActive(INTEGRATION.client); // Integration initialized INTEGRATION.initialized = true; @@ -81,18 +84,29 @@ const init = async (args) => { INTEGRATION.status = "offline"; }); - // Update sensor states on display change + // Update sensor states from notifiers HARDWARE.display.notifiers.push(() => { updateDisplay(INTEGRATION.client); }); + HARDWARE.keyboard.notifiers.push(() => { + updateKeyboard(INTEGRATION.client); + }); - // Update heartbeat time periodically (30s) + // Update time sensors periodically (30s) setInterval(() => { updateHeartbeat(INTEGRATION.client); + updateLastActive(INTEGRATION.client); }, 30 * 1000); - // Update sensor states periodically (1min) - setInterval(update, 60 * 1000); + // Update system sensors periodically (1min) + setInterval(() => { + update(); + }, 60 * 1000); + + // Update package sensors periodically (60min) + setInterval(() => { + updatePackageUpgrades(INTEGRATION.client); + }, 3600 * 1000); return true; }; @@ -108,6 +122,7 @@ const update = () => { // Update client sensors updateKiosk(INTEGRATION.client); updateUpTime(INTEGRATION.client); + updateLastActive(INTEGRATION.client); updateMemoryUsage(INTEGRATION.client); updateProcessorUsage(INTEGRATION.client); updateProcessorTemperature(INTEGRATION.client); @@ -264,6 +279,9 @@ const updateKiosk = (client) => { * @param {Object} client - Instance of the mqtt client. */ const initDisplay = (client) => { + if (!HARDWARE.support.displayStatus) { + return; + } const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`; const config = { name: `Display`, @@ -314,6 +332,62 @@ const updateDisplay = (client) => { } }; +/** + * Initializes the keyboard visibility and handles the execute logic. + * + * @param {Object} client - Instance of the mqtt client. + */ +const initKeyboard = (client) => { + if (!HARDWARE.support.keyboardVisibility) { + return; + } + const root = `${INTEGRATION.discovery}/switch/${INTEGRATION.node}/keyboard`; + const config = { + name: `Keyboard`, + unique_id: `${INTEGRATION.node}_keyboard`, + command_topic: `${root}/set`, + state_topic: `${root}/status`, + icon: "mdi:keyboard-close", + device: INTEGRATION.device, + }; + client + .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true }) + .on("message", (topic, message) => { + if (topic === `${root}/set`) { + const status = message.toString(); + console.log("Set Keyboard Visibility:", status); + hardware.setKeyboardVisibility(status); + switch (status) { + case "OFF": + WEBVIEW.window.restore(); + WEBVIEW.window.unmaximize(); + WEBVIEW.window.setFullScreen(true); + break; + case "ON": + WEBVIEW.window.restore(); + WEBVIEW.window.setFullScreen(false); + WEBVIEW.window.maximize(); + break; + } + } + }) + .subscribe(`${root}/set`); + updateKeyboard(client); +}; + +/** + * Updates the keyboard visibility via the mqtt connection. + * + * @param {Object} client - Instance of the mqtt client. + */ +const updateKeyboard = (client) => { + const visibility = hardware.getKeyboardVisibility(); + const root = `${INTEGRATION.discovery}/switch/${INTEGRATION.node}/keyboard`; + if (visibility !== null) { + client.publish(`${root}/status`, `${visibility}`, { qos: 1, retain: true }); + } +}; + /** * Initializes the model sensor. * @@ -575,6 +649,44 @@ const updateProcessorTemperature = (client) => { } }; +/** + * Initializes the package upgrades sensor. + * + * @param {Object} client - Instance of the mqtt client. + */ +const initPackageUpgrades = (client) => { + const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/package_upgrades`; + const config = { + name: `Package Upgrades`, + unique_id: `${INTEGRATION.node}_package_upgrades`, + state_topic: `${root}/status`, + json_attributes_topic: `${root}/attributes`, + value_template: "{{ value }}", + icon: "mdi:package-down", + device: INTEGRATION.device, + }; + client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true }); + updatePackageUpgrades(client); +}; + +/** + * Updates the package upgrades sensor via the mqtt connection. + * + * @param {Object} client - Instance of the mqtt client. + */ +const updatePackageUpgrades = (client) => { + const packages = hardware.checkPackageUpgrades(); + const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/package_upgrades`; + if (packages !== null) { + const attributes = { + total: packages.length, + packages: packages.map((pkg) => pkg.replace(/\s*\[.*?\]\s*/g, "").trim()), + }; + client.publish(`${root}/status`, `${attributes.total}`, { qos: 1, retain: true }); + client.publish(`${root}/attributes`, JSON.stringify(attributes), { qos: 1, retain: true }); + } +}; + /** * Initializes the heartbeat sensor. * @@ -607,6 +719,39 @@ const updateHeartbeat = (client) => { client.publish(`${root}/status`, `${heartbeat}`, { qos: 1, retain: true }); }; +/** + * Initializes the last active sensor. + * + * @param {Object} client - Instance of the mqtt client. + */ +const initLastActive = (client) => { + const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/last_active`; + const config = { + name: `Last Active`, + unique_id: `${INTEGRATION.node}_last_active`, + state_topic: `${root}/status`, + value_template: "{{ (value | float) | round(0) }}", + unit_of_measurement: "min", + icon: "mdi:gesture-tap-hold", + device: INTEGRATION.device, + }; + client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true }); + updateLastActive(client); +}; + +/** + * Updates the last active sensor via the mqtt connection. + * + * @param {Object} client - Instance of the mqtt client. + */ +const updateLastActive = (client) => { + const now = new Date(); + const then = WEBVIEW.pointer.time; + const lastActive = Math.abs(now - then) / (1000 * 60); + const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/last_active`; + client.publish(`${root}/status`, `${lastActive}`, { qos: 1, retain: true }); +}; + module.exports = { init, update, diff --git a/src/webview.js b/src/webview.js index a4a0ecc..7ed9737 100644 --- a/src/webview.js +++ b/src/webview.js @@ -1,12 +1,16 @@ const path = require("path"); const hardware = require("./hardware"); const integration = require("./integration"); -const { app, nativeTheme, BaseWindow, WebContentsView } = require("electron"); +const { app, screen, nativeTheme, BaseWindow, WebContentsView } = require("electron"); global.WEBVIEW = global.WEBVIEW || { initialized: false, status: "offline", locked: false, + pointer: { + position: {}, + time: new Date(), + }, }; /** @@ -40,6 +44,7 @@ const init = async (args) => { // Register events windowEvents(WEBVIEW.window, zoom); + touchEvents(WEBVIEW.window); viewEvents(WEBVIEW.view); return true; @@ -86,24 +91,6 @@ const windowEvents = (window, zoom) => { window.on("resize", resize); resize(); - // Handle focus change for webview lock - HARDWARE.display.notifiers.push(() => { - WEBVIEW.locked = hardware.getDisplayStatus() === "OFF"; - if (WEBVIEW.locked) { - window.blur(); - } - /* This also works, but it doesn't allow to react for screen wake up. - window.setIgnoreMouseEvents(hardware.getDisplayStatus() === "OFF"); - */ - }); - window.on("focus", () => { - if (WEBVIEW.locked) { - hardware.setDisplayStatus("ON"); - window.blur(); - } - WEBVIEW.locked = false; - }); - // Handle window status updates window.on("minimize", update); window.on("restore", update); @@ -128,6 +115,46 @@ const windowEvents = (window, zoom) => { }); }; +/** + * Register touch events and handler. + * + * @param {Object} window - The root window object. + */ +const touchEvents = (window) => { + // Handle focus change for webview lock + window.on("focus", () => { + if (WEBVIEW.locked) { + hardware.setDisplayStatus("ON"); + window.blur(); + } + WEBVIEW.locked = false; + }); + HARDWARE.display.notifiers.push(() => { + WEBVIEW.locked = hardware.getDisplayStatus() === "OFF"; + if (WEBVIEW.locked) { + window.blur(); + } + }); + + // Handle touch events for activity tracking + setInterval(() => { + const posOld = WEBVIEW.pointer.position; + const posNew = screen.getCursorScreenPoint(); + if (posOld.x !== posNew.x || posOld.y !== posNew.y) { + const now = new Date(); + const then = WEBVIEW.pointer.time; + + // Update integration sensor + WEBVIEW.pointer.time = now; + if (Math.abs(now - then) / 1000 > 30) { + console.log("Update Last Active"); + integration.update(); + } + } + WEBVIEW.pointer.position = posNew; + }, 1 * 1000); +}; + /** * Register webview events and handler. * @@ -137,8 +164,8 @@ const viewEvents = (view) => { // Enable webview touch emulation view.webContents.debugger.attach("1.1"); view.webContents.debugger.sendCommand("Emulation.setEmitTouchEventsForMouse", { - enabled: true, configuration: "mobile", + enabled: true, }); // Disable webview hyperlinks