20 Commits
Author SHA1 Message Date
leukipp 3201786d41 build: update version 2025-01-07 21:11:12 +01:00
leukipp 0656f8f897 docs: add hardware 2025-01-07 21:05:49 +01:00
leukipp dbcfb74923 docs: update readme 2025-01-07 16:45:48 +01:00
leukipp 640156ba87 ref: use code prettier 2025-01-07 16:08:07 +01:00
leukipp 8d2f3da93e feat: allow other displays and devices #2 2025-01-07 16:06:35 +01:00
leukipp 286e8c1758 fix: export display variables #1 2025-01-06 07:47:01 +01:00
leukipp c8f254d90a build: update version 2024-12-17 22:02:40 +01:00
leukipp 5e3d204019 docs: update readme 2024-12-17 21:56:30 +01:00
leukipp 926bcdfab8 feat: allow only a single instance 2024-12-17 21:54:49 +01:00
leukipp 99d3eea20d build: update version 2024-12-17 16:44:35 +01:00
leukipp 6d7e1e0bdf docs: update readme 2024-12-17 16:43:13 +01:00
leukipp bbc435ef1e ref: obfuscate arguments 2024-12-17 15:53:46 +01:00
leukipp a9b7820261 ref: prompt mqtt usage 2024-12-17 11:01:42 +01:00
leukipp d7dd3616bf ref: create udev rule for backlight 2024-12-15 21:43:47 +01:00
leukipp 7ef5f3705a docs: update install script 2024-12-15 21:16:29 +01:00
leukipp 21dbe9fd5f docs: add install script 2024-12-15 21:00:34 +01:00
leukipp eb45dd001d ref: show arguments 2024-12-15 21:00:14 +01:00
leukipp b467711914 build: update version 2024-12-15 19:51:45 +01:00
leukipp 8ac11731c8 ref: verify hardware 2024-12-15 19:51:11 +01:00
leukipp 7f54996960 ref: entry point 2024-12-15 19:49:53 +01:00
10 changed files with 929 additions and 392 deletions
+33
View File
@@ -0,0 +1,33 @@
# TouchKio - Supported Hardware
This document contains an incomplete list of device and display hardware combinations that are either intended to be tested or have already been tested.
The [release](https://github.com/leukipp/touchkio/releases) page has builds exclusively for **arm64**, but custom builds for other architectures can be made (see [Development](https://github.com/leukipp/touchkio?tab=readme-ov-file#development)), allowing the application to operate on any hardware.
At least the **minimal** features, such as displaying a **kiosk window** (which doesn't necessarily need to be Home Assistant) will work.
## Hardware
Any SBC clones of the Raspberry Pi that operate on **Raspberry Pi OS (64-bit)** and **Wayland** are likely to function as well.
### DSI
| Device | Display | Tested | Status | Notes |
| -------------- | ----------------------------------------------------------------------------------------------- | ------ | ----------------- | ------------------------------------------------------ |
| Raspberry Pi 3 | [Official 7" Touch Display 1](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | ❌ | Unknown | 99% chance that it is slow and the resolution sucks. |
| Raspberry Pi 3 | [Official 7" Touch Display 2](https://www.raspberrypi.com/products/touch-display-2/) | ❌ | Unknown | 99% chance that it is slow. |
| Raspberry Pi 4 | [Official 7" Touch Display 1](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | ❌ | Unknown | 99% chance that the resolution sucks. |
| Raspberry Pi 4 | [Official 7" Touch Display 2](https://www.raspberrypi.com/products/touch-display-2/) | ❌ | Optimistic | 99% chance that it fully works. |
| Raspberry Pi 5 | [Official 7" Touch Display 1](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | ❌ | Unknown | 99% chance that the resolution sucks. |
| Raspberry Pi 5 | [Official 7" Touch Display 2](https://www.raspberrypi.com/products/touch-display-2/) | ✅ | Fully operational | Working display power and brightness control via MQTT. |
### HDMI
| Device | Display | Tested | Status | Notes |
| -------------- | ----------------- | ------ | --------------------- | ----------------------------------------------------- |
| Raspberry Pi 4 | - | ✅ | Partially operational | - |
| Raspberry Pi 5 | Generic Non-Touch | ✅ | Partially operational | Display brightness control is not available via MQTT. |
## Contributions
In case your hardware is not listed above don't worry, give it a try.
Running `touchkio --web-url=https://demo.home-assistant.io` will most likely just work.
The only problems that may arise are when controlling the display via the Home Assistant integration.
- If you encounter any problems, please create a new [issue](https://github.com/leukipp/touchkio/issues).
- If you encounter any problems and are able to fix it yourself, feel free to create a [pull request](https://github.com/leukipp/touchkio/pulls).
- If everything works as expected and your hardware is not yet listed, you are welcome to add it and create a [pull request](https://github.com/leukipp/touchkio/pulls).
+168 -1
View File
@@ -1 +1,168 @@
# touchkio # TouchKio
**TouchKio** is a Node.js application that utilizes **Electron** to create a kiosk mode specifically designed for a Home Assistant dashboard (or any other [webpage](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md)).
This tool is packaged as a **.deb** file, making it easy to launch the kiosk mode on a **Raspberry Pi** (or similar [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md)) equipped with a touch display.
[![display](https://raw.githubusercontent.com/leukipp/touchkio/main/img/display.png)](https://github.com/leukipp/touchkio/blob/main/img/display.png)
This implementation addresses common issues encountered when using the built-in browser running in fullscreen mode on the Raspberry Pi with touch display.
Moreover, the device running the **kiosk application** also offers several **Home Assistant MQTT** sensors, enhancing it's functionality for automation purposes.
## Features
- [x] Fast and easy setup.
- [x] Touch friendly web browser interface.
- [x] Configurable zoom and theme support.
- [x] Single-tap screen wake-up functionality.
- [x] Remote controllable via MQTT.
- [x] Manage kiosk window status.
- [x] Touch display power and brightness.
- [x] Execute system reboot and shutdown commands.
- [x] Monitor temperature, processor and memory usage.
The kiosk application can be executed with command line arguments to load a **Home Assistant dashboard in fullscreen** mode.
Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Raspberry Pi and the connected touch display.
## Setup
Before you begin, make sure that you have a Raspberry Pi configured and operational with a [compatible touch display](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md).
This guide assumes that you are using the latest version of **Raspberry Pi OS (64-bit)** together with a **Wayland** based desktop environment.
### Optional
To utilize the sensor features of your display through Home Assistant, it's essential to have a **MQTT broker running** and the **MQTT integration installed** on your Home Assistant instance.
This setup allows seamless communication between your kiosk device and Home Assistant, enabling **real-time data exchange**.
[![mqtt](https://raw.githubusercontent.com/leukipp/touchkio/main/img/mqtt.png)](https://github.com/leukipp/touchkio/blob/main/img/mqtt.png)
For a comprehensive guide on setting up MQTT with Home Assistant, please refer to the official documentation available here: https://www.home-assistant.io/integrations/mqtt.
## Installation
On the first run of the application, you may encounter a **setup procedure** and the Home Assistant **login screen**.
It's recommended to create a **dedicated user** (local access only) for your kiosk device.
You might also need a keyboard or remote VNC access to input these credentials once.
#### Option 1 - The easy way
Run this command to download and install the release **.deb** file. It will also adjust some permissions, create a systemd file for auto-startup and will guide you through the setup process:
```bash
bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh)
```
If you are paranoid, or smart, or both, have a look into the [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script before executing external code on your machine.
#### Option 2 - The hard way
The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script mentioned above performs the following tasks (and you just have to do it manually):
- [Download](https://github.com/leukipp/touchkio/releases/latest) the latest **.deb** file,
which is specifically suitable for Raspberry Pi **(64-bit)** ARM architecture.
- Open a terminal and execute the following command to install the application, e.g:
`sudo dpkg -i touchkio_1.x.x_arm64.deb`
- If you just want to load a Home Assistant dashboard without further control you are good to go, e.g: `touchkio --web-url=https://demo.home-assistant.io`
If you need the application to be automatically started on boot, create a **systemd file**.
If you need the Home Assistant MQTT sensors, create the **udev rule** for backlight control.
## Configuration
Running `touchkio --setup` will prompt you to enter arguments that will be used when the application starts without any specified arguments.
These default arguments are stored in `~/.config/touchkio/Arguments.json`, where they can also be modified.
### WEB
The available arguments to control the kiosk application via terminal are as follows:
| Name | Default | Description |
| ------------------------ | ------- | ------------------------------------------------------ |
| `--web-url` (Required) | - | Url of the Home Assistant instance (HTTP(S)://IP:PORT) |
| `--web-theme` (Optional) | `dark` | Theme settings of the web browser (`light` or `dark`) |
| `--web-zoom` (Optional) | `1.25` | Zoom settings of the web browser (`1.0` is `100%`) |
These arguments allow you to customize the appearance of the web browser window.
For example:
```bash
touchkio --web-url=http://192.168.1.42:8123 --web-theme=dark --web-zoom=1.25
```
### MQTT
To broadcast your local sensor data to Home Assistant, you can use the following arguments, which require a running MQTT broker:
| Name | Default | Description |
| ----------------------------- | --------------- | ---------------------------------------------------------------------------------------- |
| `--mqtt-url` (Required) | - | Url of the MQTT broker instance (MQTT(S)://IP:PORT) |
| `--mqtt-user` (Required) | - | Username which is available in Home Assistant (e.g. create a user named `kiosk`) |
| `--mqtt-password` (Required) | - | The password of the user (e.g. use `password`, because it's secure and easy to remember) |
| `--mqtt-discovery` (Optional) | `homeassistant` | The discovery prefix for MQTT (`homeassistant` works with default setups) |
When you start the application with the MQTT arguments, the Home Assistant auto-discovery feature will automatically add controls and sensors that can be used for further integration.
You can find them under **Settings** -> **Devices and Services** -> **Devices** by searching for "Raspberry Pi".
For example:
```bash
touchkio --web-url=http://192.168.1.42:8123 --mqtt-url=mqtt://192.168.1.42:1883 --mqtt-user=kiosk --mqtt-password=password
```
## Development
If you want to create your own local build install [nodejs](https://pimylifeup.com/raspberry-pi-nodejs) and [yarn](https://classic.yarnpkg.com/lang/en/docs/install) on your Raspberry Pi.
Clone this repository and run `yarn install` to install the dependencies.
Then use `yarn start` to execute the `start` script located in the [package.json](https://github.com/leukipp/touchkio/blob/main/package.json) file.
There you can adjust the `--web-url` and other arguments for development runs.
If you connect to your Raspberry Pi via SSH, you must export the display variables so that the kiosk application can be loaded in the desktop environment:
```bash
export DISPLAY=":0"
export WAYLAND_DISPLAY="wayland-0"
```
### The nitty gritty
<details><summary>Don't waste your time reading this.</summary><div></br>
To enable **write access** to the `/sys/class/backlight/10-0045/bl_power` and `/sys/class/backlight/10-0045/brightness` files, you need to set up a **udev rule**. In case you don't use the official touch display you may have to change the rules, that are located under `/etc/udev/rules.d/backlight-permissions.rules`.
This rule is created via the [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script, which also creates a service file.
The service file is located at `~/.config/systemd/user/touchkio.service` and starts the `/usr/bin/touchkio` process when the graphical user interface is loaded.
While creating a service file is optional, it's highly recommended if you want your Raspberry Pi to automatically boot into kiosk mode.
The Raspberry Pi's **build-in screen blanking** function uses the command `swayidle -w timeout 600 'wlopm --off \*' resume 'wlopm --on \*' &` inside `~/.config/labwc/autostart` to blank the screen after **10 minutes**.
The `wlopm --off \*` command changes the `bl_power` value to **4**, when setting the value to **0** the screen will turn on again.
However, `swayidle` still seems to consider the screen to be off and as a result it will not turn off again unless there is some interaction in the meantime.
When using the MQTT integration, the kiosk application must be able to **detect changes** made on the **device** itself.
I managed to achieve this for the `brightness` file by implementing a simple `fs.watch(..)` file listener.
However, I found that it **never triggered** for the `bl_power` file.
Although the file content changes, none of the filesystem listeners where fired.
This could be due to `swayidle`/`wlopm` performing write actions at a deeper level that are not detectable by file listeners.
As a result, I went for a **polling solution**, checking the state of the file every **500 milliseconds** for any changes.
While I understand this is not ideal, it's necessary to ensure proper functionality.
The display power status and brightness can be adjusted via the MQTT integration.
**Support** for changing the power status for **DSI and HDMI** displays is achieved by checking for connected screens in `/sys/class/drm/*/status`.
Support for changing the brightness of connected display is implemented by using `/sys/class/backlight/*/brightness`.
In cases where no supported backlight device is found, the Home Assistant light entity will only show an on/off switch without brightness control.
Keep in mind that default arguments are stored as plain text in `~/.config/touchkio/Arguments.json`.
This file also includes the **MQTT user password**, which is somewhat obfuscated/encrypted, but in a way that it could be easily reverse engineered.
Implementing stronger **security measures** would complicate the setup process and could discourage some users from configuring the application properly.
When using the kiosk application without initializing the default arguments, you will need to provide them with every command.
This means that the password may be stored as plain text in various files, such as `touchkio.service`, `~/.bash_history`, etc.
To resolve the issue where the first **touch** on a **turned-off screen** triggers a **click event** (potentially activating Home Assistant actions), I implemented a workaround.
When the screen **turns off** the **focus** is removed from the kiosk window.
This way, the first click only turns the screen on and focuses the window, allowing subsequent clicks to work as expected.
Additionally, to address the problem that scrolling on the Raspberry Pi only works with the **web browser scrollbar** on the right, the Electron app is configured to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`.
This adjustment provides a user experience similar to that of a proper mobile device.
Electron apps are known to be **resource intensive** due to their architecture and the inclusion of a full web browser environment. If you just run the kiosk application without other heavy loads, everything should run smoothly.
</div></details>
## Issues
Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) documentation if you ran into any issues.
- Depending on the display hardware you are using, some features of the MQTT integration may not fully work (e.g. display brightness control).
- This is especially common when using X11 instead of Wayland (default on Raspberry Pi OS).
- You can use Raspberry Pi's build-in screen blanking functionality, however, if the screen is turned on through Home Assistant after being automatically turned off, it will remain on indefinitely.
- It's recommended to either use the built-in screen blanking feature or implement a Home Assistant automation (e.g. presence detection) to manage the screen status.
- Hyperlinks that redirect the browser away from the main window are intentionally disabled.
- This decision was made to maintain a clean design, as navigation buttons to move back and forth between different sites were not included.
- On the Raspberry Pi terminal you may see some *ERROR:gbm_wrapper.cc* messages.
- This appears to be a [known issue](https://github.com/electron/electron/issues/42322) that currently lacks a fix, but the webview still works.
## Credits
Inspired by the one and only Raspberry Pi Master Lord, [Jeff Geerling](https://www.jeffgeerling.com/blog/2024/home-assistant-and-carplay-pi-touch-display-2).
## License
[MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE)
+226
View File
@@ -0,0 +1,226 @@
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const readline = require("readline/promises");
const integration = require("./src/integration");
const hardware = require("./src/hardware");
const webview = require("./src/webview");
const { app } = require("electron");
/**
* This promise resolves when the app has finished initializing,
* allowing to safely create browser windows and perform other
* initialization tasks.
*/
app.whenReady().then(async () => {
const lock = app.requestSingleInstanceLock();
if (!lock) {
console.error("TouchKio is already running");
return app.quit();
}
let args = parseArgs(process);
let argsProvided = !!Object.keys(args).length;
let argsFilePath = path.join(app.getPath("userData"), "Arguments.json");
let argsFileExists = fs.existsSync(argsFilePath);
// Setup arguments from file path
if ("setup" in args || (!argsProvided && !argsFileExists)) {
await sleep(3000);
do {
args = await promptArgs(process);
} while (!Object.keys(args).length);
writeArgs(argsFilePath, args);
} else if (!argsProvided && argsFileExists) {
args = readArgs(argsFilePath);
}
// Show used arguments
const json = JSON.stringify(args, null, 2);
console.log(`Arguments: ${json}`);
// Chained init functions
const chained = [webview.init, hardware.init, integration.init];
for (const init of chained) {
if (!(await init(args))) {
break;
}
}
});
/**
* Parses command-line arguments from the given process object.
*
* @param {Object} proc - The process object.
* @returns {Object} An object mapping argument names to their corresponding values.
*/
const parseArgs = (proc) => {
let args = {};
proc.argv.slice(1).forEach((arg) => {
if (arg !== ".") {
const [key, value] = arg.split("=");
args[key.replace("--", "").replace("-", "_")] = value;
}
});
return args;
};
/**
* Prompts argument values on the command-line.
*
* @param {Object} proc - The process object.
* @returns {Object} An object mapping argument names to their corresponding values.
*/
const promptArgs = async (proc) => {
const read = readline.createInterface({
input: proc.stdin,
output: proc.stdout,
});
// Array of prompts
const prompts = [
{
key: "web_url",
question: "\nEnter WEB url",
fallback: "http://192.168.1.42:8123",
},
{
key: "web_theme",
question: "Enter WEB theme",
fallback: "dark",
},
{
key: "web_zoom",
question: "Enter WEB zoom level",
fallback: "1.25",
},
{
key: "mqtt",
question: "\nConnect to MQTT Broker?",
fallback: "y/N",
},
{
key: "mqtt_url",
question: "\nEnter MQTT url",
fallback: "mqtt://192.168.1.42:1883",
},
{
key: "mqtt_user",
question: "Enter MQTT username",
fallback: "kiosk",
},
{
key: "mqtt_password",
question: "Enter MQTT password",
fallback: "password",
},
{
key: "mqtt_discovery",
question: "Enter MQTT discovery prefix",
fallback: "homeassistant",
},
{
key: "check",
question: "\nEverything looks good?",
fallback: "Y/n",
},
];
// Prompt questions and wait for the answers
let args = {};
let ignore = [];
for (const { key, question, fallback } of prompts) {
if (key === "mqtt") {
const prompt = `${question} (${fallback}): `;
const answer = await read.question(prompt);
const value = (answer.trim() || fallback.match(/[YN]/)[0]).toLowerCase();
if (!["y", "yes"].includes(value)) {
ignore = ignore.concat(["mqtt_url", "mqtt_user", "mqtt_password", "mqtt_discovery"]);
}
} else if (key === "check") {
const json = JSON.stringify(args, null, 2);
const prompt = `${question}\n${json}\n(${fallback}): `;
const answer = await read.question(prompt);
const value = (answer.trim() || fallback.match(/[YN]/)[0]).toLowerCase();
if (!["y", "yes"].includes(value)) {
args = {};
}
} else if (!ignore.includes(key)) {
const prompt = `${question} (${fallback}): `;
const answer = await read.question(prompt);
const value = answer.trim() || fallback;
args[key] = value;
}
}
read.close();
return args;
};
/**
* Writes argument values to the filesystem.
*
* @param {string} path - Path of the .json file.
* @param {Object} args - The arguments object.
*/
const writeArgs = (path, args) => {
const argc = Object.assign({}, args);
if ("mqtt_password" in argc) {
argc.mqtt_password = encrypt(argc.mqtt_password);
}
fs.writeFileSync(path, JSON.stringify(argc, null, 2));
};
/**
* Reads argument values from the filesystem.
*
* @param {string} path - Path of the .json file.
* @returns {Object} The arguments object.
*/
const readArgs = (path) => {
const args = JSON.parse(fs.readFileSync(path));
if ("mqtt_password" in args) {
args.mqtt_password = decrypt(args.mqtt_password);
}
return args;
};
/**
* Helper function for string encryption.
*
* @param {string} value - Plain text value.
* @returns {string} Encrypted value.
*/
const encrypt = (value) => {
const iv = crypto.randomBytes(16);
const key = crypto.scryptSync(hardware.getMachineId(), app.getName(), 32);
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
let encrypted = cipher.update(value, "utf8", "hex");
encrypted += cipher.final("hex");
return Buffer.from(iv.toString("hex") + ":" + encrypted).toString("base64");
};
/**
* Helper function for string decryption.
*
* @param {string} value - Encrypted value.
* @returns {string} Plain text value.
*/
const decrypt = (value) => {
const p = Buffer.from(value, "base64").toString("utf8").split(":");
const iv = Buffer.from(p.shift(), "hex");
const key = crypto.scryptSync(hardware.getMachineId(), app.getName(), 32);
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
const buffer = Buffer.from(p.join(":"), "hex");
let decrypted = decipher.update(buffer, "binary", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
};
/**
* Helper function for asynchronous sleep.
*
* @param {number} ms - Sleep time in milliseconds.
* @returns {Promise} A promise resolving after the timeout.
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
Executable
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Download the latest .deb package
echo -e "Downloading the latest release..."
DEB_URL=$(wget -qO- https://api.github.com/repos/leukipp/touchkio/releases/latest | \
grep -o '"browser_download_url": "[^"]*\.deb"' | \
sed 's/"browser_download_url": "//;s/"//g')
DEB_PATH="/tmp/$(basename "$DEB_URL")"
if [ -z "$DEB_URL" ]; then
echo "Download url for .deb file not found."
exit 1
fi
if ! wget --show-progress -q -O "$DEB_PATH" "$DEB_URL"; then
echo "Failed to download the .deb file."
exit 1
fi
# Install the latest .deb package
echo -e "\nInstalling the latest release..."
if ! sudo dpkg -i "$DEB_PATH"; then
echo "Installation of .deb file failed."
exit 1
fi
# Create the systemd user service
echo -e "\nCreating systemd user service..."
SERVICE_FILE="$HOME/.config/systemd/user/touchkio.service"
mkdir -p "$(dirname "$SERVICE_FILE")" || { echo "Failed to create directory for $SERVICE_FILE"; exit 1; }
bash -c "cat << EOF > \"$SERVICE_FILE\"
[Unit]
Description=TouchKio
After=graphical.target
[Service]
ExecStart=/usr/bin/touchkio
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
EOF"
systemctl --user enable "$(basename "$SERVICE_FILE")" || { echo "Failed to enable service $SERVICE_FILE"; exit 1; }
echo "Service $SERVICE_FILE enabled"
# Create the udev rule for backlight
echo -e "\nCreating udev rule for backlight..."
UDEV_FILE="/etc/udev/rules.d/backlight-permissions.rules"
sudo bash -c "cat << EOF > \"$UDEV_FILE\"
SUBSYSTEM==\"backlight\", KERNEL==\"10-0045\", RUN+=\"/bin/chmod 666 /sys/class/backlight/%k/bl_power /sys/class/backlight/%k/brightness\"
EOF"
echo "Rule $UDEV_FILE created"
# Export display variables
echo -e "\nExporting display variables..."
if [ -z "$DISPLAY" ]; then
export DISPLAY=":0"
echo "DISPLAY was not set, defaulting to \"$DISPLAY\""
else
echo "DISPLAY is set to \"$DISPLAY\""
fi
if [ -z "$WAYLAND_DISPLAY" ]; then
export WAYLAND_DISPLAY="wayland-0"
echo "WAYLAND_DISPLAY was not set, defaulting to \"$WAYLAND_DISPLAY\""
else
echo "WAYLAND_DISPLAY is set to \"$WAYLAND_DISPLAY\""
fi
# Start the setup mode
echo ""
read -p "Start touchkio setup? (Y/n) " answer
if [[ ${answer:-y} == [Yy]* ]]; then
echo "/usr/bin/touchkio --setup"
/usr/bin/touchkio --setup
fi
-35
View File
@@ -1,35 +0,0 @@
const integration = require("./src/integration");
const hardware = require("./src/hardware");
const webview = require("./src/webview");
const { app } = require("electron");
/**
* This promise resolves when the app has finished initializing,
* allowing to safely create browser windows and perform other
* initialization tasks.
*/
app.whenReady().then(() => {
const args = parseArgs(process);
webview.init(args) && hardware.init(args) && integration.init(args);
});
/**
* Parses command-line arguments from the given process object.
*
* @param {Object} proc - The process object.
* @returns {Object} An object mapping argument names to their corresponding values.
*/
const parseArgs = (proc) => {
let argv = proc.argv.slice(1).join(" ").trim();
if (argv.length && argv[0] == ".") {
argv = argv.slice(1);
}
argv = argv.replace(/--|=/g, " ").replace(/\s+/g, " ");
argv = argv.trim().split(" ");
argv = argv.reduce(
(args, k, i, v) =>
i % 2 ? args : { ...args, [k.replace("-", "_")]: v[i + 1] },
{}
);
return argv;
};
+12 -5
View File
@@ -2,9 +2,9 @@
"name": "touchkio", "name": "touchkio",
"description": "Home Assistant Touch Kiosk", "description": "Home Assistant Touch Kiosk",
"author": "leukipp", "author": "leukipp",
"version": "0.0.1", "version": "1.0.1",
"license": "MIT", "license": "MIT",
"main": "main.js", "main": "index.js",
"scripts": { "scripts": {
"start": "electron . --web-url=https://demo.home-assistant.io", "start": "electron . --web-url=https://demo.home-assistant.io",
"build": "electron-forge make" "build": "electron-forge make"
@@ -26,8 +26,11 @@
"name": "@electron-forge/maker-deb", "name": "@electron-forge/maker-deb",
"config": { "config": {
"options": { "options": {
"productName": "TouchKio",
"icon": "img/icon.png", "icon": "img/icon.png",
"category": "Network" "categories": [
"Network"
]
} }
} }
} }
@@ -46,14 +49,18 @@
] ]
} }
}, },
"prettier": {
"printWidth": 120
},
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.6.0", "@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0", "@electron-forge/maker-deb": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0", "@electron-forge/maker-zip": "^7.6.0",
"@electron-forge/publisher-github": "^7.6.0", "@electron-forge/publisher-github": "^7.6.0",
"electron": "^33.2.1" "electron": "^33.3.1",
"prettier": "^3.4.2"
}, },
"dependencies": { "dependencies": {
"mqtt": "^5.10.3" "mqtt": "^5.10.3"
} }
} }
+202 -96
View File
@@ -1,13 +1,22 @@
const os = require("os"); const os = require("os");
const fs = require("fs"); const fs = require("fs");
const path = require("path");
const process = require("child_process"); const process = require("child_process");
global.HARDWARE = global.HARDWARE || { global.HARDWARE = global.HARDWARE || {
initialized: false, initialized: false,
status: "invalid", status: "invalid",
display: { display: {
status: null, name: null,
brightness: null, status: {
path: null,
value: null,
},
brightness: {
path: null,
value: null,
max: null,
},
notifiers: [], notifiers: [],
}, },
}; };
@@ -18,34 +27,51 @@ global.HARDWARE = global.HARDWARE || {
* @param {Object} args - The command-line arguments to customize the initialization process. * @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful. * @returns {boolean} Returns true if the initialization was successful.
*/ */
const init = (args) => { const init = async (args) => {
const model = getModel(); if (!args.mqtt_url) {
const valid = model && model.includes("Raspberry Pi 5"); return false;
// Check supported hardware
if (valid) {
console.log("\nModel:", getModel());
console.log("Serial Number:", getSerialNumber());
console.log("Host Name:", getHostName());
console.log("Up Time:", getUpTime());
console.log("Memory Size:", getMemorySize());
console.log("Memory Usage:", getMemoryUsage());
console.log("Processor Usage:", getProcessorUsage());
console.log("Processor Temperature:", getProcessorTemperature());
console.log("Display Status:", getDisplayStatus());
console.log("Display Brightness:", getDisplayBrightness(), "\n");
// Init globals
HARDWARE.initialized = true;
HARDWARE.status = "valid";
// Check for display changes
setInterval(update, 500);
} else {
console.warn(`Device ${model} not supported`);
} }
return valid; // Check device support
if (!compatibleDevice()) {
console.warn("Device not supported");
return false;
}
// Check model support
const model = getModel();
if (!model || !model.includes("Raspberry Pi")) {
console.warn(`${model} not supported`);
return false;
}
// Init globals
HARDWARE.display.name = getDisplayName();
HARDWARE.display.status.path = getDisplayStatusPath(HARDWARE.display.name);
HARDWARE.display.brightness.path = getDisplayBrightnessPath(HARDWARE.display.name);
HARDWARE.display.brightness.max = getDisplayBrightnessMax();
HARDWARE.initialized = true;
HARDWARE.status = "valid";
// Show device infos
console.log("\nModel:", model);
console.log("Serial Number:", getSerialNumber());
console.log("Host Name:", getHostName());
console.log("Up Time:", getUpTime());
console.log("Memory Size:", getMemorySize());
console.log("Memory Usage:", getMemoryUsage());
console.log("Processor Usage:", getProcessorUsage());
console.log("Processor Temperature:", getProcessorTemperature());
// Show display infos
console.log("\nDisplay Name:", HARDWARE.display.name);
console.log(`Display Status [${HARDWARE.display.status.path}]:`, getDisplayStatus());
console.log(`Display Brightness [${HARDWARE.display.brightness.path}]:`, getDisplayBrightness(), "\n");
// Check for display changes
setInterval(update, 500);
return true;
}; };
/** /**
@@ -55,29 +81,47 @@ const update = () => {
if (!HARDWARE.initialized) { if (!HARDWARE.initialized) {
return; return;
} }
const path = "/sys/class/backlight/10-0045";
// Display status has changed // Display status has changed
const status = fs.readFileSync(`${path}/bl_power`, "utf8").trim(); if (HARDWARE.display.status.path !== null) {
if (status !== HARDWARE.display.status) { const status = fs.readFileSync(`${HARDWARE.display.status.path}/enabled`, "utf8").trim();
console.log("Update Display Status:", getDisplayStatus()); if (status !== HARDWARE.display.status.value) {
HARDWARE.display.status = status; console.log("Update Display Status:", getDisplayStatus());
HARDWARE.display.notifiers.forEach((notifier) => { HARDWARE.display.status.value = status;
notifier(); HARDWARE.display.notifiers.forEach((notifier) => {
}); notifier();
});
}
} }
// Display brightness has changed // Display brightness has changed
const brightness = fs.readFileSync(`${path}/brightness`, "utf8").trim(); if (HARDWARE.display.brightness.path !== null) {
if (brightness !== HARDWARE.display.brightness) { const brightness = fs.readFileSync(`${HARDWARE.display.brightness.path}/brightness`, "utf8").trim();
console.log("Update Display Brightness:", getDisplayBrightness()); if (brightness !== HARDWARE.display.brightness.value) {
HARDWARE.display.brightness = brightness; console.log("Update Display Brightness:", getDisplayBrightness());
HARDWARE.display.notifiers.forEach((notifier) => { HARDWARE.display.brightness.value = brightness;
notifier(); HARDWARE.display.notifiers.forEach((notifier) => {
}); notifier();
});
}
} }
}; };
/**
* Verifies device compatibility by checking the presence of necessary sys files.
*
* @returns {bool} Returns true if all files exists.
*/
const compatibleDevice = () => {
const files = [
"/sys/class/drm",
"/sys/class/backlight",
"/sys/firmware/devicetree/base/model",
"/sys/firmware/devicetree/base/serial-number",
];
return files.every((file) => fs.existsSync(file));
};
/** /**
* Gets the Raspberry Pi model name using `/sys/firmware/devicetree/base/model`. * Gets the Raspberry Pi model name using `/sys/firmware/devicetree/base/model`.
* *
@@ -93,9 +137,16 @@ const getModel = () => {
* @returns {string|null} The serial number of the Raspberry Pi or null if an error occurs. * @returns {string|null} The serial number of the Raspberry Pi or null if an error occurs.
*/ */
const getSerialNumber = () => { const getSerialNumber = () => {
return execSyncCommand("cat", [ return execSyncCommand("cat", ["/sys/firmware/devicetree/base/serial-number"]);
"/sys/firmware/devicetree/base/serial-number", };
]);
/**
* Gets the host machine id using `/etc/machine-id`.
*
* @returns {string|null} The machine id of the system.
*/
const getMachineId = () => {
return execSyncCommand("cat", ["/etc/machine-id"]);
}; };
/** /**
@@ -159,6 +210,41 @@ const getProcessorTemperature = () => {
return null; return null;
}; };
/**
* Gets the primary display name using `wlopm`.
*
* @returns {string|null} The output of the command or null if an error occurs.
*/
const getDisplayName = () => {
const status = execSyncCommand("wlopm", []);
if (status !== null) {
const out = status.split("\n")[0].split(" ");
return out.shift();
}
return null;
};
/**
* Gets the current display status path using `/sys/class/drm`.
*
* @param {string} name - Primary display name.
* @returns {string|null} The display status path or null if nothing was found.
*/
const getDisplayStatusPath = (name) => {
const drm = "/sys/class/drm";
for (const card of fs.readdirSync(drm)) {
const statusFile = path.join(drm, card, "status");
if (!fs.existsSync(statusFile)) {
continue;
}
const content = fs.readFileSync(statusFile, "utf8").trim();
if (card.includes(name) && content === "connected") {
return path.join(drm, card);
}
}
return null;
};
/** /**
* Gets the current display power status using `wlopm`. * Gets the current display power status using `wlopm`.
* *
@@ -167,18 +253,10 @@ const getProcessorTemperature = () => {
const getDisplayStatus = () => { const getDisplayStatus = () => {
const status = execSyncCommand("wlopm", []); const status = execSyncCommand("wlopm", []);
if (status !== null) { if (status !== null) {
return status.slice(status.lastIndexOf(" ") + 1).toUpperCase(); const out = status.split("\n")[0].split(" ");
return out.pop().toUpperCase();
} }
return null; return null;
/* This also works, but it's better to use `wlopm` for getter and setter methods.
const status = execSyncCommand("cat", [
"/sys/class/backlight/10-0045/bl_power",
]);
if (status !== null) {
return status === "0" ? "ON" : "OFF";
}
*/
}; };
/** /**
@@ -192,64 +270,89 @@ const getDisplayStatus = () => {
*/ */
const setDisplayStatus = (status, callback = null) => { const setDisplayStatus = (status, callback = null) => {
if (status !== "ON" && status !== "OFF") { if (status !== "ON" && status !== "OFF") {
console.error("Status must be 'ON' or 'OFF'."); console.error("Status must be 'ON' or 'OFF'");
if (typeof callback === "function") callback(null, "Invalid status."); if (typeof callback === "function") callback(null, "Invalid status");
return; return;
} }
const args = status === "ON" ? ["--on", "*"] : ["--off", "*"]; const args = [`--${status.toLowerCase()}`, HARDWARE.display.name];
execAsyncCommand("wlopm", args, callback); execAsyncCommand("wlopm", args, callback);
/* This also works, but interferes with the built-in screen blanking function.
const value = status === "ON" ? "0" : "4";
const proc = execAsyncCommand(
"tee",
["/sys/class/backlight/10-0045/bl_power"],
callback
);
proc.stdin.write(value.toString());
proc.stdin.end();
*/
}; };
/** /**
* Gets the current display brightness level using `/sys/class/backlight/10-0045/brightness`. * Gets the current display brightness path using `/sys/class/backlight`.
* *
* This function retrieves the brightness level, which is in the range of 0-31, * @param {string} name - Primary display name.
* and maps it to a percentage in the range of 0-100 percent. * @returns {string|null} The display brightness path or null if nothing was found.
*
* @returns {number|null} The brightness level as a percentage or null if an error occurs.
*/ */
const getDisplayBrightness = () => { const getDisplayBrightnessPath = (name) => {
const brightness = execSyncCommand("cat", [ const backlight = "/sys/class/backlight";
"/sys/class/backlight/10-0045/brightness", for (const address of fs.readdirSync(backlight)) {
]); const nameFile = path.join(backlight, address, "display_name");
if (brightness !== null) { if (!fs.existsSync(nameFile)) {
return Math.round((parseInt(brightness, 10) / 31) * 100); continue;
}
const content = fs.readFileSync(nameFile, "utf8").trim();
if (content.includes(name)) {
return path.join(backlight, address);
}
} }
return null; return null;
}; };
/** /**
* Sets the display brightness level using `/sys/class/backlight/10-0045/brightness`. * Gets the maximum display brightness value using `/sys/class/backlight/.../max_brightness`.
* *
* This function takes a brightness value in the range of 1-100 percent, * @returns {number|null} The brightness maximum value or null if an error occurs.
* maps it to the range of 0-31, and writes it to the system. */
const getDisplayBrightnessMax = () => {
if (!HARDWARE.display.brightness.path) {
return null;
}
const max = execSyncCommand("cat", [`${HARDWARE.display.brightness.path}/max_brightness`]);
if (max !== null) {
return parseInt(max, 10);
}
return null;
};
/**
* Gets the current display brightness level using `/sys/class/backlight/.../brightness`.
*
* @returns {number|null} The brightness level as a percentage or null if an error occurs.
*/
const getDisplayBrightness = () => {
if (!HARDWARE.display.brightness.path) {
return null;
}
const brightness = execSyncCommand("cat", [`${HARDWARE.display.brightness.path}/brightness`]);
if (brightness !== null) {
const max = HARDWARE.display.brightness.max;
return Math.round((parseInt(brightness, 10) / max) * 100);
}
return null;
};
/**
* Sets the display brightness level using `/sys/class/backlight/.../brightness`.
*
* This function takes a brightness value between 1 to 100 percent,
* maps it to the proper range and writes it to the system.
* *
* @param {number} brightness - The desired brightness level (1-100). * @param {number} brightness - The desired brightness level (1-100).
* @param {function} callback - A callback function that receives the output or error. * @param {function} callback - A callback function that receives the output or error.
*/ */
const setDisplayBrightness = (brightness, callback = null) => { const setDisplayBrightness = (brightness, callback = null) => {
if (typeof brightness !== "number" || brightness < 1 || brightness > 100) { if (!HARDWARE.display.brightness.path) {
console.error("Brightness must be a number between 1 and 100.");
if (typeof callback === "function") callback(null, "Invalid brightness.");
return; return;
} }
const value = Math.max(1, Math.min(Math.round((brightness / 100) * 31), 31)); if (typeof brightness !== "number" || brightness < 1 || brightness > 100) {
const proc = execAsyncCommand( console.error("Brightness must be a number between 1 and 100");
"tee", if (typeof callback === "function") callback(null, "Invalid brightness");
["/sys/class/backlight/10-0045/brightness"], return;
callback }
); const max = HARDWARE.display.brightness.max;
const value = Math.max(1, Math.min(Math.round((brightness / 100) * max), max));
const proc = execAsyncCommand("tee", [`${HARDWARE.display.brightness.path}/brightness`], callback);
proc.stdin.write(value.toString()); proc.stdin.write(value.toString());
proc.stdin.end(); proc.stdin.end();
}; };
@@ -287,9 +390,7 @@ const rebootSystem = (callback = null) => {
*/ */
const execSyncCommand = (cmd, args = []) => { const execSyncCommand = (cmd, args = []) => {
try { try {
const output = process.execSync([cmd, ...args].join(" "), { const output = process.execSync([cmd, ...args].join(" "), { encoding: "utf8" });
encoding: "utf8",
});
return output.trim().replace(/\0/g, ""); return output.trim().replace(/\0/g, "");
} catch (error) { } catch (error) {
console.error("Execute Sync:", error.message); console.error("Execute Sync:", error.message);
@@ -340,14 +441,19 @@ module.exports = {
update, update,
getModel, getModel,
getSerialNumber, getSerialNumber,
getMachineId,
getHostName, getHostName,
getUpTime, getUpTime,
getMemorySize, getMemorySize,
getMemoryUsage, getMemoryUsage,
getProcessorUsage, getProcessorUsage,
getProcessorTemperature, getProcessorTemperature,
getDisplayName,
getDisplayStatusPath,
getDisplayStatus, getDisplayStatus,
setDisplayStatus, setDisplayStatus,
getDisplayBrightnessPath,
getDisplayBrightnessMax,
getDisplayBrightness, getDisplayBrightness,
setDisplayBrightness, setDisplayBrightness,
shutdownSystem, shutdownSystem,
+174 -234
View File
@@ -13,7 +13,7 @@ global.INTEGRATION = global.INTEGRATION || {
* @param {Object} args - The command-line arguments to customize the initialization process. * @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful. * @returns {boolean} Returns true if the initialization was successful.
*/ */
const init = (args) => { const init = async (args) => {
if (!args.mqtt_url) { if (!args.mqtt_url) {
return false; return false;
} }
@@ -90,12 +90,12 @@ const init = (args) => {
updateDisplay(INTEGRATION.client); updateDisplay(INTEGRATION.client);
}); });
// Update heartbeat time periodically // Update heartbeat time periodically (30s)
setInterval(() => { setInterval(() => {
updateHeartbeat(INTEGRATION.client); updateHeartbeat(INTEGRATION.client);
}, 30 * 1000); }, 30 * 1000);
// Update sensor states periodically // Update sensor states periodically (1min)
setInterval(update, 60 * 1000); setInterval(update, 60 * 1000);
return true; return true;
@@ -124,18 +124,15 @@ const update = () => {
*/ */
const initShutdown = (client) => { const initShutdown = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/shutdown`; const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/shutdown`;
const config = {
name: "Shutdown",
unique_id: `${INTEGRATION.node}_shutdown`,
command_topic: `${root}/execute`,
icon: "mdi:power",
device: INTEGRATION.device,
};
client client
.publish( .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
`${root}/config`,
JSON.stringify({
name: "Shutdown",
unique_id: `${INTEGRATION.node}_shutdown`,
command_topic: `${root}/execute`,
icon: "mdi:power",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === `${root}/execute`) { if (topic === `${root}/execute`) {
console.log("Shutdown system..."); console.log("Shutdown system...");
@@ -153,18 +150,15 @@ const initShutdown = (client) => {
*/ */
const initReboot = (client) => { const initReboot = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/reboot`; const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/reboot`;
const config = {
name: "Reboot",
unique_id: `${INTEGRATION.node}_reboot`,
command_topic: `${root}/execute`,
icon: "mdi:restart",
device: INTEGRATION.device,
};
client client
.publish( .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
`${root}/config`,
JSON.stringify({
name: "Reboot",
unique_id: `${INTEGRATION.node}_reboot`,
command_topic: `${root}/execute`,
icon: "mdi:restart",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === `${root}/execute`) { if (topic === `${root}/execute`) {
console.log("Rebooting system..."); console.log("Rebooting system...");
@@ -182,21 +176,18 @@ const initReboot = (client) => {
*/ */
const initRefresh = (client) => { const initRefresh = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/refresh`; const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/refresh`;
const config = {
name: "Refresh",
unique_id: `${INTEGRATION.node}_refresh`,
command_topic: `${root}/execute`,
icon: "mdi:web-refresh",
device: INTEGRATION.device,
};
client client
.publish( .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
`${root}/config`,
JSON.stringify({
name: "Refresh",
unique_id: `${INTEGRATION.node}_refresh`,
command_topic: `${root}/execute`,
icon: "mdi:web-refresh",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === `${root}/execute`) { if (topic === `${root}/execute`) {
console.log("Refresh webview..."); console.log("Refreshing webview...");
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON");
WEBVIEW.view.webContents.reloadIgnoringCache(); WEBVIEW.view.webContents.reloadIgnoringCache();
} }
@@ -211,27 +202,18 @@ const initRefresh = (client) => {
*/ */
const initKiosk = (client) => { const initKiosk = (client) => {
const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`; const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`;
const config = {
name: `Kiosk`,
unique_id: `${INTEGRATION.node}_kiosk`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
options: ["Framed", "Fullscreen", "Maximized", "Minimized", "Terminated"],
icon: "mdi:overscan",
device: INTEGRATION.device,
};
client client
.publish( .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
`${root}/config`,
JSON.stringify({
name: `Kiosk`,
unique_id: `${INTEGRATION.node}_kiosk`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
options: [
"Framed",
"Fullscreen",
"Maximized",
"Minimized",
"Terminated",
],
icon: "mdi:overscan",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === `${root}/set`) { if (topic === `${root}/set`) {
const status = message.toString(); const status = message.toString();
@@ -275,10 +257,9 @@ const initKiosk = (client) => {
const updateKiosk = (client) => { const updateKiosk = (client) => {
const kiosk = WEBVIEW.status; const kiosk = WEBVIEW.status;
const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`; const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`;
client.publish(`${root}/status`, `${kiosk}`, { if (kiosk !== null) {
qos: 1, client.publish(`${root}/status`, `${kiosk}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -288,23 +269,22 @@ const updateKiosk = (client) => {
*/ */
const initDisplay = (client) => { const initDisplay = (client) => {
const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`; const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`;
const config = {
name: `Display`,
unique_id: `${INTEGRATION.node}_display`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
icon: "mdi:monitor-shimmer",
platform: "light",
device: INTEGRATION.device,
};
if (HARDWARE.display.brightness.path !== null) {
config.brightness_scale = 100;
config.brightness_command_topic = `${root}/brightness/set`;
config.brightness_state_topic = `${root}/brightness/status`;
}
client client
.publish( .publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
`${root}/config`,
JSON.stringify({
name: `Display`,
unique_id: `${INTEGRATION.node}_display`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
brightness_command_topic: `${root}/brightness/set`,
brightness_state_topic: `${root}/brightness/status`,
brightness_scale: 100,
icon: "mdi:monitor-shimmer",
platform: "light",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === `${root}/set`) { if (topic === `${root}/set`) {
const status = message.toString(); const status = message.toString();
@@ -330,14 +310,12 @@ const updateDisplay = (client) => {
const status = hardware.getDisplayStatus(); const status = hardware.getDisplayStatus();
const brightness = hardware.getDisplayBrightness(); const brightness = hardware.getDisplayBrightness();
const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`; const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`;
client.publish(`${root}/status`, `${status}`, { if (status !== null) {
qos: 1, client.publish(`${root}/status`, `${status}`, { qos: 1, retain: true });
retain: true, }
}); if (brightness !== null) {
client.publish(`${root}/brightness/status`, `${brightness}`, { client.publish(`${root}/brightness/status`, `${brightness}`, { qos: 1, retain: true });
qos: 1, }
retain: true,
});
}; };
/** /**
@@ -347,18 +325,15 @@ const updateDisplay = (client) => {
*/ */
const initModel = (client) => { const initModel = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`;
client.publish( const config = {
`${root}/config`, name: `Model`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_model`,
name: `Model`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_model`, value_template: "{{ value }}",
state_topic: `${root}/status`, icon: "mdi:raspberry-pi",
value_template: "{{ value }}", device: INTEGRATION.device,
icon: "mdi:raspberry-pi", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateModel(client); updateModel(client);
}; };
@@ -370,10 +345,9 @@ const initModel = (client) => {
const updateModel = (client) => { const updateModel = (client) => {
const model = hardware.getModel(); const model = hardware.getModel();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`;
client.publish(`${root}/status`, `${model}`, { if (model !== null) {
qos: 1, client.publish(`${root}/status`, `${model}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -383,18 +357,15 @@ const updateModel = (client) => {
*/ */
const initSerialNumber = (client) => { const initSerialNumber = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`;
client.publish( const config = {
`${root}/config`, name: `Serial Number`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_serial_number`,
name: `Serial Number`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_serial_number`, value_template: "{{ value }}",
state_topic: `${root}/status`, icon: "mdi:hexadecimal",
value_template: "{{ value }}", device: INTEGRATION.device,
icon: "mdi:hexadecimal", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateSerialNumber(client); updateSerialNumber(client);
}; };
@@ -406,10 +377,9 @@ const initSerialNumber = (client) => {
const updateSerialNumber = (client) => { const updateSerialNumber = (client) => {
const serialNumber = hardware.getSerialNumber(); const serialNumber = hardware.getSerialNumber();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`;
client.publish(`${root}/status`, `${serialNumber}`, { if (serialNumber !== null) {
qos: 1, client.publish(`${root}/status`, `${serialNumber}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -419,18 +389,15 @@ const updateSerialNumber = (client) => {
*/ */
const initHostName = (client) => { const initHostName = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`;
client.publish( const config = {
`${root}/config`, name: `Host Name`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_host_name`,
name: `Host Name`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_host_name`, value_template: "{{ value }}",
state_topic: `${root}/status`, icon: "mdi:console-network",
value_template: "{{ value }}", device: INTEGRATION.device,
icon: "mdi:console-network", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateHostName(client); updateHostName(client);
}; };
@@ -442,10 +409,9 @@ const initHostName = (client) => {
const updateHostName = (client) => { const updateHostName = (client) => {
const hostName = hardware.getHostName(); const hostName = hardware.getHostName();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`;
client.publish(`${root}/status`, `${hostName}`, { if (hostName !== null) {
qos: 1, client.publish(`${root}/status`, `${hostName}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -455,19 +421,16 @@ const updateHostName = (client) => {
*/ */
const initUpTime = (client) => { const initUpTime = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`;
client.publish( const config = {
`${root}/config`, name: `Up Time`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_up_time`,
name: `Up Time`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_up_time`, value_template: "{{ (value | float) | round(0) }}",
state_topic: `${root}/status`, unit_of_measurement: "min",
value_template: "{{ (value | float) | round(0) }}", icon: "mdi:timeline-clock",
unit_of_measurement: "min", device: INTEGRATION.device,
icon: "mdi:timeline-clock", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateUpTime(client); updateUpTime(client);
}; };
@@ -479,10 +442,9 @@ const initUpTime = (client) => {
const updateUpTime = (client) => { const updateUpTime = (client) => {
const upTime = hardware.getUpTime(); const upTime = hardware.getUpTime();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`;
client.publish(`${root}/status`, `${upTime}`, { if (upTime !== null) {
qos: 1, client.publish(`${root}/status`, `${upTime}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -492,19 +454,16 @@ const updateUpTime = (client) => {
*/ */
const initMemorySize = (client) => { const initMemorySize = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`;
client.publish( const config = {
`${root}/config`, name: `Memory Size`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_memory_size`,
name: `Memory Size`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_memory_size`, value_template: "{{ (value | float) | round(2) }}",
state_topic: `${root}/status`, unit_of_measurement: "GiB",
value_template: "{{ (value | float) | round(2) }}", icon: "mdi:memory",
unit_of_measurement: "GiB", device: INTEGRATION.device,
icon: "mdi:memory", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateMemorySize(client); updateMemorySize(client);
}; };
@@ -516,10 +475,9 @@ const initMemorySize = (client) => {
const updateMemorySize = (client) => { const updateMemorySize = (client) => {
const memorySize = hardware.getMemorySize(); const memorySize = hardware.getMemorySize();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`;
client.publish(`${root}/status`, `${memorySize}`, { if (memorySize !== null) {
qos: 1, client.publish(`${root}/status`, `${memorySize}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -529,19 +487,16 @@ const updateMemorySize = (client) => {
*/ */
const initMemoryUsage = (client) => { const initMemoryUsage = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`;
client.publish( const config = {
`${root}/config`, name: `Memory Usage`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_memory_usage`,
name: `Memory Usage`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_memory_usage`, value_template: "{{ (value | float) | round(0) }}",
state_topic: `${root}/status`, unit_of_measurement: "%",
value_template: "{{ (value | float) | round(0) }}", icon: "mdi:memory-arrow-down",
unit_of_measurement: "%", device: INTEGRATION.device,
icon: "mdi:memory-arrow-down", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateMemoryUsage(client); updateMemoryUsage(client);
}; };
@@ -553,10 +508,9 @@ const initMemoryUsage = (client) => {
const updateMemoryUsage = (client) => { const updateMemoryUsage = (client) => {
const memoryUsage = hardware.getMemoryUsage(); const memoryUsage = hardware.getMemoryUsage();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`;
client.publish(`${root}/status`, `${memoryUsage}`, { if (memoryUsage !== null) {
qos: 1, client.publish(`${root}/status`, `${memoryUsage}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -566,19 +520,16 @@ const updateMemoryUsage = (client) => {
*/ */
const initProcessorUsage = (client) => { const initProcessorUsage = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`;
client.publish( const config = {
`${root}/config`, name: `Processor Usage`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_processor_usage`,
name: `Processor Usage`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_processor_usage`, value_template: "{{ (value | float) | round(0) }}",
state_topic: `${root}/status`, unit_of_measurement: "%",
value_template: "{{ (value | float) | round(0) }}", icon: "mdi:cpu-64-bit",
unit_of_measurement: "%", device: INTEGRATION.device,
icon: "mdi:cpu-64-bit", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateProcessorUsage(client); updateProcessorUsage(client);
}; };
@@ -590,10 +541,9 @@ const initProcessorUsage = (client) => {
const updateProcessorUsage = (client) => { const updateProcessorUsage = (client) => {
const processorUsage = hardware.getProcessorUsage(); const processorUsage = hardware.getProcessorUsage();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`;
client.publish(`${root}/status`, `${processorUsage}`, { if (processorUsage !== null) {
qos: 1, client.publish(`${root}/status`, `${processorUsage}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -603,19 +553,16 @@ const updateProcessorUsage = (client) => {
*/ */
const initProcessorTemperature = (client) => { const initProcessorTemperature = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`;
client.publish( const config = {
`${root}/config`, name: `Processor Temperature`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_processor_temperature`,
name: `Processor Temperature`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_processor_temperature`, value_template: "{{ (value | float) | round(0) }}",
state_topic: `${root}/status`, unit_of_measurement: "°C",
value_template: "{{ (value | float) | round(0) }}", icon: "mdi:radiator",
unit_of_measurement: "°C", device: INTEGRATION.device,
icon: "mdi:radiator", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateProcessorTemperature(client); updateProcessorTemperature(client);
}; };
@@ -627,10 +574,9 @@ const initProcessorTemperature = (client) => {
const updateProcessorTemperature = (client) => { const updateProcessorTemperature = (client) => {
const processorTemperature = hardware.getProcessorTemperature(); const processorTemperature = hardware.getProcessorTemperature();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`;
client.publish(`${root}/status`, `${processorTemperature}`, { if (processorTemperature !== null) {
qos: 1, client.publish(`${root}/status`, `${processorTemperature}`, { qos: 1, retain: true });
retain: true, }
});
}; };
/** /**
@@ -640,18 +586,15 @@ const updateProcessorTemperature = (client) => {
*/ */
const initHeartbeat = (client) => { const initHeartbeat = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`;
client.publish( const config = {
`${root}/config`, name: `Heartbeat`,
JSON.stringify({ unique_id: `${INTEGRATION.node}_heartbeat`,
name: `Heartbeat`, state_topic: `${root}/status`,
unique_id: `${INTEGRATION.node}_heartbeat`, value_template: "{{ value }}",
state_topic: `${root}/status`, icon: "mdi:heart-flash",
value_template: "{{ value }}", device: INTEGRATION.device,
icon: "mdi:heart-flash", };
device: INTEGRATION.device, client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
}),
{ qos: 1, retain: true }
);
updateHeartbeat(client); updateHeartbeat(client);
}; };
@@ -665,10 +608,7 @@ const updateHeartbeat = (client) => {
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000); const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, ""); const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, "");
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`; const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`;
client.publish(`${root}/status`, `${heartbeat}`, { client.publish(`${root}/status`, `${heartbeat}`, { qos: 1, retain: true });
qos: 1,
retain: true,
});
}; };
module.exports = { module.exports = {
+20 -15
View File
@@ -15,21 +15,19 @@ global.WEBVIEW = global.WEBVIEW || {
* @param {Object} args - The command-line arguments to customize the initialization process. * @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful. * @returns {boolean} Returns true if the initialization was successful.
*/ */
const init = (args) => { const init = async (args) => {
if (!args.web_url) { if (!args.web_url) {
console.error("Please provide the '--web-url' parameter."); console.error("Please provide the '--web-url' parameter");
return app.quit(); return app.quit();
} }
const url = new URL(args.web_url); const url = new URL(args.web_url);
const theme = args.web_theme ? args.web_theme : "dark"; const theme = args.web_theme ? args.web_theme : "dark";
const zoom = args.web_zoom ? parseFloat(args.web_zoom) : 1.25; const zoom = args.web_zoom ? parseFloat(args.web_zoom) : 1.25;
// Set app theme and name
nativeTheme.themeSource = theme;
app.setName("TouchKio");
// Init global root window // Init global root window
nativeTheme.themeSource = theme;
WEBVIEW.window = new BaseWindow({ WEBVIEW.window = new BaseWindow({
title: `TouchKio - ${url.host}`,
icon: path.join(__dirname, "..", "img", "icon.png"), icon: path.join(__dirname, "..", "img", "icon.png"),
}); });
WEBVIEW.window.setMenuBarVisibility(false); WEBVIEW.window.setMenuBarVisibility(false);
@@ -95,7 +93,7 @@ const windowEvents = (window, zoom) => {
window.blur(); window.blur();
} }
/* This also works, but it doesn't allow to react for screen wake up. /* This also works, but it doesn't allow to react for screen wake up.
window.setIgnoreMouseEvents(getDisplayStatus() === "OFF"); window.setIgnoreMouseEvents(hardware.getDisplayStatus() === "OFF");
*/ */
}); });
window.on("focus", () => { window.on("focus", () => {
@@ -106,18 +104,28 @@ const windowEvents = (window, zoom) => {
WEBVIEW.locked = false; WEBVIEW.locked = false;
}); });
// Update window status // Handle window status updates
window.on("minimize", update); window.on("minimize", update);
window.on("restore", update); window.on("restore", update);
window.on("maximize", update); window.on("maximize", update);
window.on("unmaximize", update); window.on("unmaximize", update);
window.on("enter-full-screen", update); window.on("enter-full-screen", update);
window.on("leave-full-screen", update); window.on("leave-full-screen", update);
// Handle signal and exit events
process.on("SIGINT", app.quit); process.on("SIGINT", app.quit);
app.on("before-quit", () => { app.on("before-quit", () => {
WEBVIEW.status = "Terminated"; WEBVIEW.status = "Terminated";
integration.update(); integration.update();
}); });
// Handle multiple instances
app.on("second-instance", () => {
if (window.isMinimized()) {
window.restore();
}
window.focus();
});
}; };
/** /**
@@ -128,13 +136,10 @@ const windowEvents = (window, zoom) => {
const viewEvents = (view) => { const viewEvents = (view) => {
// Enable webview touch emulation // Enable webview touch emulation
view.webContents.debugger.attach("1.1"); view.webContents.debugger.attach("1.1");
view.webContents.debugger.sendCommand( view.webContents.debugger.sendCommand("Emulation.setEmitTouchEventsForMouse", {
"Emulation.setEmitTouchEventsForMouse", enabled: true,
{ configuration: "mobile",
enabled: true, });
configuration: "mobile",
}
);
// Disable webview hyperlinks // Disable webview hyperlinks
view.webContents.setWindowOpenHandler(() => { view.webContents.setWindowOpenHandler(() => {
+11 -6
View File
@@ -246,9 +246,9 @@
optionalDependencies: optionalDependencies:
global-agent "^3.0.0" global-agent "^3.0.0"
"@electron/node-gyp@git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2": "@electron/node-gyp@https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2":
version "10.2.0-electron.1" version "10.2.0-electron.1"
resolved "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2" resolved "https://github.com/electron/node-gyp#06b29aafb7708acef8b3669835c8a7857ebc92d2"
dependencies: dependencies:
env-paths "^2.2.0" env-paths "^2.2.0"
exponential-backoff "^3.1.1" exponential-backoff "^3.1.1"
@@ -1129,10 +1129,10 @@ electron-installer-debian@^3.2.0:
word-wrap "^1.2.3" word-wrap "^1.2.3"
yargs "^16.0.2" yargs "^16.0.2"
electron@^33.2.1: electron@^33.3.1:
version "33.2.1" version "33.3.1"
resolved "https://registry.yarnpkg.com/electron/-/electron-33.2.1.tgz#d0d7bba7a7abf4f14881d0a6e03c498b301a2d5f" resolved "https://registry.yarnpkg.com/electron/-/electron-33.3.1.tgz#26d63b3a110c4a9db639c33633bf806f91b4f649"
integrity sha512-SG/nmSsK9Qg1p6wAW+ZfqU+AV8cmXMTIklUL18NnOKfZLlum4ZsDoVdmmmlL39ZmeCaq27dr7CgslRPahfoVJg== integrity sha512-Z7l2bVgpdKxHQMI4i0CirBX2n+iCYKOx5mbzNM3BpOyFELwlobEXKmzCmEnwP+3EcNeIhUQyIEBFQxN06QgdIw==
dependencies: dependencies:
"@electron/get" "^2.0.0" "@electron/get" "^2.0.0"
"@types/node" "^20.9.0" "@types/node" "^20.9.0"
@@ -2396,6 +2396,11 @@ postject@^1.0.0-alpha.6:
dependencies: dependencies:
commander "^9.4.0" commander "^9.4.0"
prettier@^3.4.2:
version "3.4.2"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f"
integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==
proc-log@^2.0.1: proc-log@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-2.0.1.tgz#8f3f69a1f608de27878f91f5c688b225391cb685" resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-2.0.1.tgz#8f3f69a1f608de27878f91f5c688b225391cb685"