73 Commits
Author SHA1 Message Date
leukipp b8f809c50e build: update version 2025-08-31 22:04:56 +02:00
leukipp c3e625a8f6 docs: update readme 2025-08-31 22:01:18 +02:00
leukipp 7c15707e3b docs: add hardware 2025-08-31 21:06:57 +02:00
leukipp dbd3756793 ref: update images 2025-08-31 20:33:07 +02:00
leukipp 85fe5a910a fix: update service file #62 2025-08-31 20:31:38 +02:00
leukipp d78088e6af feat: side panel pager #46 2025-08-31 19:24:50 +02:00
leukipp bf77d9b2a0 fix: mqtt memory leak #63 2025-08-31 16:09:39 +02:00
leukipp f4ca7bc412 feat: basic keyboard navigation #46 2025-08-31 11:41:12 +02:00
leukipp d0ea377ead feat: add page zoom buttons #48 2025-08-31 09:24:30 +02:00
leukipp 9a829f72e3 build: update dependencies 2025-08-31 08:48:24 +02:00
leukipp 482a56c9b9 build: update dependencies 2025-08-27 20:03:26 +02:00
leukipp 32409983b2 fix: remember zoom level on refresh #52 2025-08-27 20:02:59 +02:00
leukipp fe0667919e feat: check online status #62 2025-08-27 18:30:15 +02:00
leukipp 0037a892cd ref: display status fallback 2025-08-27 18:26:40 +02:00
leukipp ad22dd3215 feat: kde wayland support #57 2025-08-26 17:15:21 +02:00
leukipp 619179c3c8 fix: event based communication #39 #48 #55 2025-08-25 18:52:25 +02:00
afsy-d 7e5e669b09 Added hardware information (#56)
* Update HARDWARE.md

Added CM4 light 2GB information

* Update HARDWARE.md

Updated information regarding CM4
2025-08-25 13:31:51 +02:00
Tom Carpenter 437c4ab1c5 Make Webview Zoom Changeable from Home Assistant (#52)
* Add Page Zoom Control

Adds MQTT discovery for a "Page Zoom" input to control the `WEBVIEW.viewZoom` parameter.

The MQTT value has been configured to be in % which is then converted to a scale factor when assigned or read from `WEBVIEW.viewZoom`.

* Correct Type for page_zoom publishConfig

Accidentally replaced "number" with "zoom" when changing paramters.

* Add updateZoom Functionality to webview

Add a setter function for the viewZoom property that updates the zoom factor for all views when the property is changed.
2025-08-25 13:31:28 +02:00
leukipp ea01876ea7 build: update version 2025-07-10 21:31:48 +02:00
leukipp 9de170ce6c docs: update readme 2025-07-10 21:16:52 +02:00
leukipp 6b3e89d522 build: update dependencies 2025-07-10 21:15:19 +02:00
leukipp cbfd0538e7 feat: auto-hide navigation bar #45 2025-07-10 20:54:32 +02:00
leukipp d3af6d397e docs: add hardware 2025-07-10 13:58:32 +02:00
leukipp dd81da204e ref: update images 2025-07-10 13:23:53 +02:00
leukipp 00aef6402e fix: use apt instead of dpkg #28 2025-07-09 20:21:24 +02:00
leukipp a36c624601 ref: show version info 2025-07-09 20:20:07 +02:00
leukipp adbc214f4d feat: battery power level over mqtt #33 2025-07-09 19:14:36 +02:00
leukipp e64f092f6c ref: navigation buttons 2025-07-09 16:11:28 +02:00
leukipp cab2052477 ref: move and rename mqtt topics 2025-07-08 19:40:43 +02:00
leukipp 0d66d104fb fix: command line single arguments #40 2025-07-08 19:24:14 +02:00
leukipp 1afe335379 feat: multi webpage support #25 2025-07-08 14:20:19 +02:00
Miha Frangež 568be07043 Fix for URL containing query params (#40) 2025-07-06 16:21:37 +02:00
leukipp 9d70b09358 ref: update data types 2025-06-25 14:10:10 +02:00
leukipp 6570d5c00e fix: return default values #39 2025-06-24 11:15:56 +02:00
leukipp af7d0c58ce fix: show web url error #34 2025-06-24 10:59:32 +02:00
leukipp aed79ab519 build: update version 2025-02-23 22:32:18 +01:00
leukipp e2397bad65 ref: update status values 2025-02-23 22:31:31 +01:00
leukipp e7622bef17 ref: use null status 2025-02-23 22:18:25 +01:00
leukipp 2553f52983 build: downgrade dependencies 2025-02-23 22:15:36 +01:00
leukipp 30b5ec2d8b docs: add hardware 2025-02-23 21:13:50 +01:00
leukipp 94c7e34706 docs: update readme 2025-02-23 20:54:02 +01:00
leukipp 09dda65333 ref: start touchkio 2025-02-23 20:29:23 +01:00
leukipp 99f465a044 build: update dependencies 2025-02-23 20:18:26 +01:00
leukipp 02d1309345 Merge branch 'main' into develop 2025-02-23 16:48:19 +01:00
Sebastian 3142606d12 Update README.md (#20)
Adding info on how to control the automatically created service
2025-02-23 16:46:30 +01:00
leukipp 51189e8595 fix: reduce file system read cycle #19 2025-02-23 16:45:40 +01:00
leukipp 71a9d4c1de ref: code cleanup 2025-02-23 16:38:23 +01:00
leukipp f022db9478 ref: use async update 2025-02-23 15:15:06 +01:00
leukipp fcc0e5813e feat: check for DISPLAY variable #12 2025-02-23 13:36:46 +01:00
leukipp 6d23efd713 ref: add debug arguments 2025-02-23 13:01:14 +01:00
leukipp a37226fd39 build: update version 2025-02-03 09:14:12 +01:00
leukipp 9b8067a598 docs: update readme 2025-02-03 09:10:46 +01:00
leukipp 257dab8d7f docs: add hardware 2025-02-02 23:49:41 +01:00
Rajeevan ff4b9d1262 Update HARDWARE.md (#14)
Adding my 10.1inch display that works.. tested uinsg X11, Wayland (Wayfire & Labwc)
2025-02-02 22:41:53 +01:00
leukipp 757ba9ff92 docs: update docs 2025-02-02 22:38:06 +01:00
leukipp ed3bad7292 ref: update images 2025-02-02 22:30:39 +01:00
leukipp f00a49e4e4 feat: side panel widget #16 2025-02-02 20:28:22 +01:00
leukipp bbe20fef8f ref: code cleanup 2025-02-02 16:22:07 +01:00
leukipp cfbc391a52 build: rename artifacts 2025-01-10 22:41:42 +01:00
leukipp b1998ceea9 build: update version 2025-01-10 22:31:22 +01:00
leukipp 081cee2990 docs: update docs 2025-01-10 22:30:17 +01:00
leukipp fd746cc79a feat: additional MQTT controls and sensors #7 2025-01-10 17:11:11 +01:00
leukipp 4f426df4e6 build: use x64 names 2025-01-09 17:14:19 +01:00
leukipp 7084fc8188 build: add amd64 releases 2025-01-09 14:36:09 +01:00
leukipp b2685f8d6b feat: support for x11 and generic linux #5 2025-01-09 13:37:26 +01:00
leukipp 5743b0ff08 Merge pull request #3 from pdsccode/main
docs: added working HDMI display to compatibility list #3
2025-01-08 18:05:23 +01:00
pdsccode a7f4ede899 Added working HDMI-Display to compatibility list 2025-01-08 14:56:10 +01:00
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
20 changed files with 4062 additions and 1940 deletions
+1 -4
View File
@@ -8,9 +8,6 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
arch: [arm64]
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -30,4 +27,4 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: | run: |
yarn electron-forge publish --arch arm64 yarn electron-forge publish --arch arm64,x64
+54
View File
@@ -0,0 +1,54 @@
# Supported Hardware
This document contains an incomplete list of device and display hardware combinations that have already been tested.
The [release](https://github.com/leukipp/touchkio/releases) page has builds exclusively for **arm64** and **x64**, 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
If you are running Linux with a graphical user interface (Wayland or X11), you should be well equipped to use the application. Additionally, any single board computer (SBC) clones of the Raspberry Pi that operate on Raspberry Pi OS **(64-bit)** are likely to function as well.
| | Status | Notes |
| --- | --------------------- | ------------------------------------------------------------------------- |
| 🟩 | Fully operational | Working display power, brightness and keyboard control via MQTT. |
| 🟨 | Partially operational | Display brightness control is not available via MQTT. |
| 🟧 | Partially operational | Display brightness and keyboard control is not available via MQTT. |
| 🟥 | Partially operational | Display power, brightness and keyboard control is not available via MQTT. |
| ⬜ | Somehow operational | Issues can occur and the overall performance is very slow. |
| ⬛ | Not operational | The house is on fire. |
### DSI
| Device | System | Display | Status |
| ---------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------ |
| Raspberry Pi 3 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 1 (800x480)](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | ⬜ |
| Raspberry Pi 3 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 2 (720x1280)](https://www.raspberrypi.com/products/touch-display-2/) | ⬜ |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 1 (800x480)](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | 🟩 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 2 (720x1280)](https://www.raspberrypi.com/products/touch-display-2/) | 🟩 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 1 (800x480)](https://www.raspberrypi.com/products/raspberry-pi-touch-display/) | 🟩 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Official 7" Touch Display 2 (720x1280)](https://www.raspberrypi.com/products/touch-display-2/) | 🟩 |
### HDMI
| Device | System | Display | Status |
| ------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------ |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [LAFVIN 5" Touch Display (800x480)](https://www.amazon.de/gp/product/B0BWJ8YP7S) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 8" CAPLCD (768x1024)](https://www.waveshare.com/wiki/8inch_768x1024_LCD) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 7" 7EP-CAPLCD (1280x800)](https://www.waveshare.com/7ep-caplcd.htm) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Hannspree HT225 21.5" (1920x1080)](https://www.hannspree.eu/product/HT-225-HPB) | 🟨 |
| Raspberry Pi 400 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [UPerfect Vertical Touch 15.6" (1920x1080)](https://uperfect.com/products/uperfect-y-vertical-monitor-15-6) | 🟨 |
| Raspberry Pi CM 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Lenovo ThinkCentre 23.8" (1920x1080)](https://psref.lenovo.com/Detail/ThinkCentre_Tiny_In_One_24_Gen_5?M=12NBGAT1EU) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [GeeekPi 10.1" Capacitive Touch (1280x800)](https://www.amazon.nl/dp/B0DHV6DZC1) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Prechen Portable Touch 18.5" FHD (1920x1080)](https://www.amazon.de/dp/B0CT2KLDBQ) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Akntzcs Portable Touch 16" HD (1920x1200)](https://www.amazon.com/dp/B0CTGW6MQ6) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [ELECROW Portable Monitor 10.1" (1280x800)](https://www.amazon.co.uk/dp/B0BHHQLKPY) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | Generic Non-Touch | 🟨 |
| Generic PC (x64) | Debian KDE (64-bit), Wayland, X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu GNOME (64-bit), X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu GNOME (64-bit), Wayland | Generic Non-Touch | 🟥 |
## 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 or keyboard 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 [report](https://github.com/leukipp/touchkio/issues/12) it or create a [pull request](https://github.com/leukipp/touchkio/pulls).
+179 -48
View File
@@ -1,32 +1,40 @@
# TouchKio # TouchKio
**TouchKio** is a Node.js application that utilizes **Electron** to create a kiosk mode specifically designed for a Home Assistant dashboard. **TouchKio** is a Node.js application that utilizes Electron to create a kiosk mode window specifically designed for a Home Assistant dashboard.
This tool is packaged as a **.deb** file, making it easy to launch the kiosk mode on a **Raspberry Pi** equipped with the official 7" touch display. This tool is packaged as a **.deb** file, making it easy to launch the kiosk application on any Debian based Linux [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) (e.g. **Raspberry Pi**) equipped with a **DSI or HDMI** Touch Display.
Additional releases for other Linux systems are available as **.zip** file.
[![display](https://raw.githubusercontent.com/leukipp/touchkio/main/img/display.png)](https://github.com/leukipp/touchkio/blob/main/img/display.png) [![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 touch display. This implementation addresses common issues encountered when using the built-in browser running in fullscreen mode on a Linux device with Touch Display.
Moreover, the device running the **kiosk application** also offers several **Home Assistant MQTT** sensors, enhancing it's functionality for automation purposes. Moreover, the device running the **kiosk application** also offers several **Home Assistant MQTT** sensors, enhancing it's functionality for automation purposes.
## Features ## Features
- [x] Fast and easy setup. - [x] Fast and easy setup.
- [x] Touch friendly web browser interface. - [x] Remember login credentials.
- [x] Configurable zoom and theme support. - [x] Touch optimized web browsing.
- [x] Single-tap screen wake-up functionality. - [x] Side panel widget for kiosk control.
- [x] Navigation bar to switch between pages.
- [x] Configurable browser zoom and theme support.
- [x] Extended touch screen wake-up functionality.
- [x] Remote controllable via MQTT. - [x] Remote controllable via MQTT.
- [x] Manage kiosk window status. - [x] Manage kiosk window status.
- [x] Toggle the on-screen keyboard.
- [x] Touch display power and brightness. - [x] Touch display power and brightness.
- [x] Execute system reboot and shutdown commands. - [x] Multi-webpage switching and url navigation.
- [x] Monitor temperature, processor and memory usage. - [x] Monitor temperature, processor and memory usage.
- [x] Execute system reboot and shutdown commands.
- [x] Show available system package upgrades.
The kiosk application can be executed with command line arguments to load a **Home Assistant dashboard in fullscreen** mode. 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. Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Linux device and the connected Touch Display.
## Setup ## Setup
Before you begin, make sure that you have a Raspberry Pi configured and operational with a compatible touch display. Before you begin, make sure that you have a Linux device configured and operational with a [compatible](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) Touch Display.
This guide assumes that you are using the latest version of **Raspberry Pi OS (64-bit)** together with a desktop environment. This guide assumes that you are using a Raspberry Pi with the latest version of Raspberry Pi OS **(64-bit)**, along with a desktop environment (preferred using **labwc**).
However, the **.deb** setup procedure is also compatible with any other Debian based 64-bit system.
### Optional ### 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. To utilize the sensor features of your device 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**. 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) [![mqtt](https://raw.githubusercontent.com/leukipp/touchkio/main/img/mqtt.png)](https://github.com/leukipp/touchkio/blob/main/img/mqtt.png)
@@ -36,25 +44,50 @@ For a comprehensive guide on setting up MQTT with Home Assistant, please refer t
## Installation ## Installation
On the first run of the application, you may encounter a **setup procedure** and the Home Assistant **login screen**. 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. 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.
You might also need a physical keyboard or remote VNC access to input these credentials once.
If your hardware is [supported](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) you may be able to activate the on-screen keyboard using the side [widget](https://github.com/leukipp/touchkio/issues/16).
#### Option 1 - The easy way #### 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: Run this command to download and install the latest **.deb** (arm64 or x64) release.
It will also create a systemd user file for auto-startup and will guide you through the setup process:
```bash ```bash
bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh) bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh)
``` ```
Make sure that you run this with your **standard user** and not with root (sudo).
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. 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.
This command can also be re-run to **update** an existing installation to the latest version.
#### Option 2 - The hard way The systemd **user service** is enabled by default and the kiosk application should start automatically the next time you boot.
If you need manual control use:
```bash
systemctl --user start|stop|status|restart touchkio.service
```
<details><summary>Alternatives</summary><div>
#### Option 2 - The standard way
When connected via SSH, it's necessary to export the display variables first, as outlined in the [development](https://github.com/leukipp/touchkio?tab=readme-ov-file#development) section.
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): 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, - [Download](https://github.com/leukipp/touchkio/releases/latest) the latest version file that is suitable for your architecture (arm64 or x64).
which is specifically suitable for Raspberry Pi **(64-bit)** ARM architecture. - Debian (**deb**): Open a terminal and execute the following command to install the application, e.g:
- Open a terminal and execute the following command to install the application, e.g: `sudo apt install ./touchkio_1.x.x_arm64.deb && touchkio --setup`
`sudo dpkg -i touchkio_1.x.x_arm64.deb` - Others (**zip**): Extract the archive and run the binary, e.g: `unzip touchkio-linux-x64-1.x.x.zip && cd touchkio-linux-x64 && ./touchkio --setup`
- 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 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`
- The `--web-url` doesn't necessarily need to be a Home Assistant url, any kind of website can be shown in kiosk mode.
- Only when using the MQTT integration via `--mqtt-*`, a running Home Assistant instance is required.
- If you need the application to be automatically started on boot, create a systemd file.
If you need the application to be automatically started on boot, create a **systemd file**. #### Option 3 - The hard way
If you need the Home Assistant MQTT sensors, create the **udev rule** for backlight control. Pre-built release files are available for arm64 and x64 Linux systems.
If you are using a different architecture, you can still utilize this repository to build your own application.
For more information please refer to the [development](https://github.com/leukipp/touchkio?tab=readme-ov-file#development) section, however this will do the job:
```bash
yarn build
```
</div></details>
## Configuration ## Configuration
Running `touchkio --setup` will prompt you to enter arguments that will be used when the application starts without any specified arguments. Running `touchkio --setup` will prompt you to enter arguments that will be used when the application starts without any specified arguments.
@@ -62,17 +95,32 @@ These default arguments are stored in `~/.config/touchkio/Arguments.json`, where
### WEB ### WEB
The available arguments to control the kiosk application via terminal are as follows: The available arguments to control the kiosk application via terminal are as follows:
| Name | Default | Description | | Name | Default | Description |
| ------------------------ | ------- | ------------------------------------------------------ | | ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `--web-url` (Required) | - | Url of the Home Assistant instance (HTTP(S)://IP:PORT) | | `--web-url` (Required) | - | Url of the Home Assistant instance<a id="ref1"></a><sup><a href="#foot1">[1]</a></sup> (HTTP(S)://IP:PORT) |
| `--web-theme` (Optional) | `dark` | Theme settings of the web browser (`light` or `dark`) | | `--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%`) | | `--web-zoom` (Optional) | `1.25` | Zoom settings of the web browser (`1.0` is `100%`) |
| `--web-widget` (Optional) | `true` | Enables the sidebar widget (`true` or `false`) |
These arguments allow you to customize the appearance of the web browser window. These arguments allow you to customize the appearance of the web browser view.
For example: For example:
```bash ```bash
touchkio --web-url=http://192.168.1.42:8123 --web-theme=dark --web-zoom=1.25 touchkio --web-url=http://192.168.1.42:8123 --web-theme=light --web-zoom=1.0
```
> <a id="foot1"></a><a href="#ref1">[1]</a>: This doesn't necessarily have to be a Home Assistant Url.
> You can configure multiple pages by separating them with a comma:
> `touchkio --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app`.
In the `~/.config/touchkio/Arguments.json` file:
```json
{
"web_url": [
"https://demo.home-assistant.io",
"https://demo.immichkiosk.app"
]
}
``` ```
### MQTT ### MQTT
@@ -85,27 +133,91 @@ To broadcast your local sensor data to Home Assistant, you can use the following
| `--mqtt-discovery` (Optional) | `homeassistant` | The discovery prefix for MQTT (`homeassistant` works with default setups) | | `--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. 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". You can find them under **Settings** -> **Devices and Services** -> **Devices** by searching for **TouchKio**.
For example: For example:
```bash ```bash
touchkio --web-url=http://192.168.1.42:8123 --mqtt-url=mqtt://192.168.1.42:1883 --mqtt-user=kiosk --mqtt-password=password touchkio --web-url=http://192.168.1.42:8123 --mqtt-url=mqtt://192.168.1.42:1883 --mqtt-user=kiosk --mqtt-password=password
``` ```
## User Interface
TouchKio provides a simple webview window designed specifically for Touch Displays. 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.
### Controls
Additional controls can be found along the right edge of the kiosk application. For more details, you may want to have a closer look here:
| Name | Description |
| ------------ | -------------------------------------------------------- |
| `Widget` | [See #16](https://github.com/leukipp/touchkio/issues/16) |
| `Navigation` | [See #45](https://github.com/leukipp/touchkio/issues/45) |
| `Pager` | [See #64](https://github.com/leukipp/touchkio/issues/64) |
### Keyboard Shortcuts
The application also supports basic shortcuts to enhance navigation and usability for users who prefer or require non-touch input methods:
| Name | Description |
| --------------------------- | --------------------------- |
| `Control+Left` | Navigate to previous page |
| `Control+Right` | Navigate to next page |
| `Control+Num_Subtract` | Decrease the page zoom |
| `Control+Num_Add` | Increase the page zoom |
| `Alt+Left`/`Mouse+Back` | Navigate back in history |
| `Alt+Right`/`Mouse+Forward` | Navigate forward in history |
## Development ## 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. To create your own local build, you first need to install [Node.js](https://pimylifeup.com/raspberry-pi-nodejs) and [Yarn](https://classic.yarnpkg.com/lang/en/docs/install).
Clone this repository and run `yarn install` to install the dependencies. 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` file. 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. There you may want to adjust the `--web-url` and other arguments for development runs.
If you access your device over SSH, make sure to export the display variables so the kiosk application can launch in the desktop environment.
```bash
export DISPLAY=":0"
export WAYLAND_DISPLAY="wayland-0"
```
To make this permanent, consider adding the export variables into the `~/.bashrc` file.
### Extensions
<details><summary>You probably won't need this.</summary><div></br>
Incorporating custom extensions and external hardware (like motion sensors, ultrasonic sensors, cameras, relays and switches) via **Raspberry Pi's GPIO/USB** involves several steps.
While using external sensors that directly integrate with Home Assistant and by utilizing automation's to interact with **TouchKio via MQTT** is generally easier and **recommended**, here's a rough guide on how to proceed with custom hardware integration:
1. **Install Node.js library**: Use Yarn to add a library that can interact with your hardware (GPIO, USB, etc.):
```bash
yarn add [package-name]
```
This will update the `package.json` file with the required dependencies.
2. **Import the library**: Open the `hardware.js` file and import the library using:
```javascript
const package = require("[package-name]");
```
Then implement your custom methods and logic to handle the hardware. Don't forget to export the methods at the end of the file:
```javascript
module.exports = { ... };
```
3. **Expose sensors via MQTT**: If you want to publish sensor data through MQTT, implement some init and update methods in the `integration.js` file:
```javascript
const initCustomSensor = (client) => { ... }
const updateCustomSensor = (client) => { ... }
```
To get started have a look at the existing methods. Don't forget to call the custom sensor initialization method inside the global init method, where the MQTT connection is established:
```javascript
const init = async (args) => { ... }
```
From there, you will need to further refine your code by tinkering with sensor updates. This can be achieved through either periodic update calls or event based triggers.
</div></details>
### The nitty gritty ### The nitty gritty
<details><summary>Don't waste your time reading this.</summary><div></br> <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**. The Raspberry Pi's **build-in on-screen keyboard** named `squeekboard` (it squeaks because some *Rust* got inside), is specifically designed for Wayland environments and features a **D-Bus interface** that allows applications to show or hide the keyboard as needed.
This is done within the `install.sh` script, which also creates a `.service` file. The kiosk application interacts with squeekboard via the `D-Bus` object path `/sm/puri/OSK0`, enabling the keyboard to be hidden or shown based on **MQTT** user input or system events.
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 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. The `wlopm --off \*` command changes the `bl_power` value to **4**, when setting the value to **0** the screen will turn on again.
@@ -116,38 +228,57 @@ I managed to achieve this for the `brightness` file by implementing a simple `fs
However, I found that it **never triggered** for the `bl_power` file. However, I found that it **never triggered** for the `bl_power` file.
Although the file content changes, none of the filesystem listeners where fired. 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. 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 both files every **500 milliseconds** for any changes. As a result, I went for a **polling solution**, checking the state of the file **every second** for any changes.
While I understand this is not ideal, it's necessary to ensure proper functionality. 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 on **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`. 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. 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. 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. 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. 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. To resolve the issue where the first **touch** on a **turned-off screen** triggers a **click event** (which could inadvertently activate Home Assistant actions), a workaround needed to be implemented.
When the screen **turns off**, I remove **focus** from the kiosk window. 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. 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, I configured the Electron app to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`. Additionally, to address the problem that scrolling only works with the **web browser scrollbar** on the right, the application is configured to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`.
This adjustment provides a user experience similar to that of a proper mobile device. 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> </div></details>
## Issues ## Issues
- Currently, only the Raspberry Pi 5 in combination with the new touch display 2 supports the MQTT integration. Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) documentation if you ran into any issues.
- Other variations, such as Raspberry Pi 4 or Raspberry Pi 5 with touch display 1, could likely be added, but this will require additional testing and contributions via pull requests.
- 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. **Minimal features** are designed to run on any system without issues:
- 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. - A webview kiosk window launched in fullscreen mode and loading the specified `--web-url` website should not cause any problems.
- 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. **Extended features** become available when the `--mqtt-*` arguments are provided and the hardware is supported:
- On the Raspberry Pi terminal you may see some *ERROR:gbm_wrapper.cc* messages. - If your hardware is not fully compatible there should be no crashes, but you may miss some sensors.
- On some Debian based systems (e.g. Ubuntu GNOME), the display status control may only be available when using X11.
- The following commands are currently implemented to modify the display status.
- `wlopm --[on]/[off] *` (Raspberry Pi OS, Wayland)
- `kscreen-doctor --dpms [on]/[off]` (Debian KDE, Wayland)
- `xset dpms force [on]/[off]` (Generic, X11)
**Known Issues** that are by-design or for which there isn't a solution so far:
- You can use Raspberry Pi's build-in [screen blanking](https://www.raspberrypi.com/documentation/computers/configuration.html#screen-blanking-3) 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.
- When using a Raspberry Pi, the on-screen keyboard doesn't automatically pop-out when entering a text field inside the webview.
- As a current [workaround](https://github.com/leukipp/touchkio/issues/4) you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the keyboard visibility.
- Certain commands from the MQTT integration may require elevated privileges to work correctly.
- Ensure that your local user has the [necessary permissions](https://github.com/leukipp/touchkio/issues/39) to run `sudo` without being prompted for a password.
- On the 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. - 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 ## 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). Thanks to Sebastian ([@pdsccode](https://github.com/pdsccode)) for his contributions on issues and [community](https://community.home-assistant.io/t/kiosk-mode-for-raspberry-pi-with-touch-display/821196) discussions.
[Inspired by](https://www.jeffgeerling.com/blog/2024/home-assistant-and-carplay-pi-touch-display-2) the one and only Raspberry Pi Master, Jeff Geerling ([@geerlingguy](https://github.com/geerlingguy)).
## License ## License
[MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE) [MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE)
+52
View File
@@ -0,0 +1,52 @@
const fs = require("fs");
module.exports = {
packagerConfig: {
ignore: [".github", ".gitignore", "install.sh", "forge.config.js"],
},
makers: [
{
name: "@electron-forge/maker-deb",
config: {
options: {
productName: "TouchKio",
productDescription: "Kiosk mode application for a Home Assistant dashboard",
categories: ["Network"],
icon: "img/icon.png",
},
},
},
{
name: "@electron-forge/maker-zip",
},
],
publishers: [
{
name: "@electron-forge/publisher-github",
config: {
repository: {
owner: "leukipp",
name: "touchkio",
},
draft: true,
},
},
],
hooks: {
postMake: async (config, results) => {
for (const result of results) {
const artifacts = [];
result.artifacts.forEach((artifact) => {
if (artifact.includes("amd64")) {
const renamed = artifact.replace("amd64", "x64");
fs.renameSync(artifact, renamed);
artifacts.push(renamed);
} else {
artifacts.push(artifact);
}
});
result.artifacts = artifacts;
}
},
},
};
+227
View File
@@ -0,0 +1,227 @@
<!doctype html>
<html>
<head>
<style>
[data-theme="dark"] {
--primary-color: rgba(225, 225, 225, 1);
--border-color: rgba(225, 225, 225, 0.15);
--background-color: rgba(28, 28, 28, 1);
}
[data-theme="light"] {
--primary-color: rgba(33, 33, 33, 1);
--border-color: rgba(33, 33, 33, 0.15);
--background-color: rgba(255, 255, 255, 1);
}
::-webkit-scrollbar {
display: none;
}
* {
margin: 0;
padding: 0;
cursor: pointer;
}
body {
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
}
menu {
display: flex;
flex-direction: row;
align-items: center;
width: 100vw;
height: 100vh;
padding: 0 30px;
column-gap: 20px;
list-style-type: none;
border-top: 1px solid var(--border-color);
background: var(--background-color);
}
li {
display: flex;
align-items: center;
flex: 0 0 auto;
}
li.dynamic {
flex: 1 1 0%;
}
li.separator {
height: 32px;
border: 1px solid var(--border-color);
}
li.separator:has(+ li.hidden) {
display: none;
}
li.hidden {
display: none;
}
li.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
button {
width: 32px;
height: 32px;
border: none;
background-color: transparent;
}
input {
width: 100%;
min-width: 0;
margin: 0 5px;
padding: 6px 10px;
border-radius: 4px;
font-size: 18px;
outline: none;
border: none;
cursor: text;
box-sizing: border-box;
color: var(--primary-color);
background-color: var(--border-color);
}
svg {
pointer-events: none;
fill: var(--primary-color);
}
</style>
</head>
<body>
<menu>
<li>
<button id="home">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>home-circle</title>
<path
d="M19.07,4.93C17.22,3 14.66,1.96 12,2C9.34,1.96 6.79,3 4.94,4.93C3,6.78 1.96,9.34 2,12C1.96,14.66 3,17.21 4.93,19.06C6.78,21 9.34,22.04 12,22C14.66,22.04 17.21,21 19.06,19.07C21,17.22 22.04,14.66 22,12C22.04,9.34 21,6.78 19.07,4.93M17,12V18H13.5V13H10.5V18H7V12H5L12,5L19.5,12H17Z"
/>
</svg>
</button>
</li>
<li>
<button id="refresh">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>refresh-circle</title>
<path
d="M12 2A10 10 0 1 0 22 12A10 10 0 0 0 12 2M18 11H13L14.81 9.19A3.94 3.94 0 0 0 12 8A4 4 0 1 0 15.86 13H17.91A6 6 0 1 1 12 6A5.91 5.91 0 0 1 16.22 7.78L18 6Z"
/>
</svg>
</button>
</li>
<li class="separator"></li>
<li>
<button id="previous">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>page-previous</title>
<path
d="M4,21H19A2,2 0 0,0 21,19V13H17V15L13,12L17,9V11H21V5A2,2 0 0,0 19,3H4A2,2 0 0,0 2,5V19A2,2 0 0,0 4,21M4,15H8V17H4V15M4,11H11V13H4V11M4,7H11V9H4V7M21,11H24V13H21V11Z"
/>
</svg>
</button>
</li>
<li>
<button id="next">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>page-next</title>
<path
d="M20,3H5A2,2 0 0,0 3,5V11H7V9L11,12L7,15V13H3V19A2,2 0 0,0 5,21H20A2,2 0 0,0 22,19V5A2,2 0 0,0 20,3M17,17H13V15H17V17M20,13H13V11H20V13M20,9H13V7H20V9M3,13H0V11H3V13Z"
/>
</svg>
</button>
</li>
<li class="separator"></li>
<li class="dynamic">
<input id="url" type="text" spellcheck="false" />
</li>
<li class="separator"></li>
<li>
<button id="backward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>history</title>
<path
d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3"
/>
</svg>
</button>
</li>
<li>
<button id="forward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>update</title>
<path
d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2C4.35,9.91 4.35,14.28 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.12,16.65 18.36,18.39C14.85,21.87 9.15,21.87 5.64,18.39C2.14,14.92 2.11,9.28 5.62,5.81C9.13,2.34 14.76,2.34 18.27,5.81L21,3V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"
/>
</svg>
</button>
</li>
<li class="separator"></li>
<li>
<button id="minus">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>magnify-minus</title>
<path
d="M9,2A7,7 0 0,1 16,9C16,10.57 15.5,12 14.61,13.19L15.41,14H16L22,20L20,22L14,16V15.41L13.19,14.61C12,15.5 10.57,16 9,16A7,7 0 0,1 2,9A7,7 0 0,1 9,2M5,8V10H13V8H5Z"
/>
</svg>
</button>
</li>
<li>
<button id="plus">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>magnify-plus</title>
<path
d="M9,2A7,7 0 0,1 16,9C16,10.57 15.5,12 14.61,13.19L15.41,14H16L22,20L20,22L14,16V15.41L13.19,14.61C12,15.5 10.57,16 9,16A7,7 0 0,1 2,9A7,7 0 0,1 9,2M8,5V8H5V10H8V13H10V10H13V8H10V5H8Z"
/>
</svg>
</button>
</li>
</menu>
<script>
const { ipcRenderer } = require("electron");
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
document.querySelectorAll("input").forEach((input) => {
input.addEventListener("focus", (e) => {
ipcRenderer.send("input-focus", { id: e.target.id });
});
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
ipcRenderer.send("input-enter", { id: e.target.id, text: e.target.value });
}
});
});
};
const registerReceiveEvents = () => {
ipcRenderer.on("button-disabled", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("disabled", button.disabled);
});
ipcRenderer.on("button-hidden", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("hidden", button.hidden);
});
ipcRenderer.on("input-blur", (e, input) => {
document.getElementById(input.id).blur();
});
ipcRenderer.on("input-select", (e, input) => {
document.getElementById(input.id).select();
});
ipcRenderer.on("input-text", (e, input) => {
document.getElementById(input.id).value = input.text;
document.getElementById(input.id).placeholder = input.placeholder;
});
ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme);
});
};
registerSendEvents();
registerReceiveEvents();
</script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html>
<head>
<style>
[data-theme="hidden"] {
--primary-color: rgba(0, 0, 0, 0);
--border-color: rgba(0, 0, 0, 0);
--background-color: rgba(0, 0, 0, 0);
}
::-webkit-scrollbar {
display: none;
}
* {
margin: 0;
padding: 0;
cursor: none;
}
body {
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
}
menu {
display: flex;
flex-direction: column;
align-items: center;
width: 100vw;
height: 100vh;
list-style-type: none;
background: var(--background-color);
}
li {
display: flex;
align-items: center;
flex: 0 0 auto;
}
button {
width: 100vw;
height: 50vh;
border: none;
background-color: transparent;
}
#previous {
cursor: w-resize;
}
#next {
cursor: e-resize;
}
</style>
</head>
<body>
<menu>
<li>
<button id="previous"></button>
</li>
<li>
<button id="next"></button>
</li>
</menu>
<script>
const { ipcRenderer } = require("electron");
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
};
const registerReceiveEvents = () => {
ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme);
});
};
registerSendEvents();
registerReceiveEvents();
</script>
</body>
</html>
+136
View File
@@ -0,0 +1,136 @@
<!doctype html>
<html>
<head>
<style>
[data-theme="dark"] {
--primary-color: rgba(225, 225, 225, 1);
--border-color: rgba(225, 225, 225, 0.15);
--background-color: rgba(28, 28, 28, 1);
}
[data-theme="light"] {
--primary-color: rgba(33, 33, 33, 1);
--border-color: rgba(33, 33, 33, 0.15);
--background-color: rgba(255, 255, 255, 1);
}
[data-theme="hidden"] {
--primary-color: rgba(0, 0, 0, 0);
--border-color: rgba(0, 0, 0, 0);
--background-color: rgba(0, 0, 0, 0);
}
::-webkit-scrollbar {
display: none;
}
* {
margin: 0;
padding: 0;
cursor: pointer;
}
body {
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
}
menu {
display: flex;
flex-direction: column;
align-items: center;
width: 100vw;
padding: 10px;
row-gap: 10px;
list-style-type: none;
border-top-left-radius: 15px;
border-bottom-left-radius: 15px;
border-top: 1px solid var(--border-color);
border-left: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
background: var(--background-color);
}
li {
display: flex;
align-items: center;
flex: 0 0 auto;
}
li.hidden {
display: none;
}
button {
width: 32px;
height: 32px;
border: none;
background-color: transparent;
}
svg {
pointer-events: none;
fill: var(--primary-color);
}
</style>
</head>
<body>
<menu>
<li>
<button id="keyboard">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>keyboard-close-outline</title>
<path
d="M12 23L16 19H8M4 3C2.9 3 2 3.9 2 5V15C2 16.1 2.9 17 4 17H20C21.1 17 22 16.1 22 15V5C22 3.9 21.1 3 20 3H4M4 5H20V15H4V5M5 6V8H7V6H5M8 6V8H10V6H8M11 6V8H13V6H11M14 6V8H16V6H14M17 6V8H19V6H17M5 9V11H7V9H5M8 9V11H10V9H8M11 9V11H13V9H11M14 9V11H16V9H14M17 9V11H19V9H17M8 12V14H16V12H8Z"
/>
</svg>
</button>
</li>
<li>
<button id="fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>overscan</title>
<path
d="M12 5.5L10 8H14L12 5.5M18 10V14L20.5 12L18 10M6 10L3.5 12L6 14V10M14 16H10L12 18.5L14 16M21 3H3C1.9 3 1 3.9 1 5V19C1 20.1 1.9 21 3 21H21C22.1 21 23 20.1 23 19V5C23 3.9 22.1 3 21 3M21 19H3V5H21V19Z"
/>
</svg>
</button>
</li>
<li>
<button id="minimize">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>image-size-select-small</title>
<path
d="M23,15H21V17H23V15M23,11H21V13H23V11M23,19H21V21C22,21 23,20 23,19M15,3H13V5H15V3M23,7H21V9H23V7M21,3V5H23C23,4 22,3 21,3M3,21H11V15H1V19A2,2 0 0,0 3,21M3,7H1V9H3V7M15,19H13V21H15V19M19,3H17V5H19V3M19,19H17V21H19V19M3,3C2,3 1,4 1,5H3V3M3,11H1V13H3V11M11,3H9V5H11V3M7,3H5V5H7V3Z"
/>
</svg>
</button>
</li>
<li>
<button id="navigation">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>web</title>
<path
d="M16.36,14C16.44,13.34 16.5,12.68 16.5,12C16.5,11.32 16.44,10.66 16.36,10H19.74C19.9,10.64 20,11.31 20,12C20,12.69 19.9,13.36 19.74,14M14.59,19.56C15.19,18.45 15.65,17.25 15.97,16H18.92C17.96,17.65 16.43,18.93 14.59,19.56M14.34,14H9.66C9.56,13.34 9.5,12.68 9.5,12C9.5,11.32 9.56,10.65 9.66,10H14.34C14.43,10.65 14.5,11.32 14.5,12C14.5,12.68 14.43,13.34 14.34,14M12,19.96C11.17,18.76 10.5,17.43 10.09,16H13.91C13.5,17.43 12.83,18.76 12,19.96M8,8H5.08C6.03,6.34 7.57,5.06 9.4,4.44C8.8,5.55 8.35,6.75 8,8M5.08,16H8C8.35,17.25 8.8,18.45 9.4,19.56C7.57,18.93 6.03,17.65 5.08,16M4.26,14C4.1,13.36 4,12.69 4,12C4,11.31 4.1,10.64 4.26,10H7.64C7.56,10.66 7.5,11.32 7.5,12C7.5,12.68 7.56,13.34 7.64,14M12,4.03C12.83,5.23 13.5,6.57 13.91,8H10.09C10.5,6.57 11.17,5.23 12,4.03M18.92,8H15.97C15.65,6.75 15.19,5.55 14.59,4.44C16.43,5.07 17.96,6.34 18.92,8M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"
/>
</svg>
</button>
</li>
</menu>
<script>
const { ipcRenderer } = require("electron");
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
};
const registerReceiveEvents = () => {
ipcRenderer.on("button-hidden", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("hidden", button.hidden);
});
ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme);
});
};
registerSendEvents();
registerReceiveEvents();
</script>
</body>
</html>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 154 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+49 -22
View File
@@ -2,11 +2,21 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const readline = require("readline/promises"); const readline = require("readline/promises");
const integration = require("./src/integration"); const integration = require("./js/integration");
const hardware = require("./src/hardware"); const hardware = require("./js/hardware");
const webview = require("./src/webview"); const webview = require("./js/webview");
const { app } = require("electron"); const { app } = require("electron");
global.ARGS = global.ARGS || {};
if (!process.env.DISPLAY) {
console.error(`\n$DISPLAY variable not set to run the GUI application, are you connected via SSH?\n`);
console.error(`If you have installed the service use:`);
console.error(` systemctl --user start touchkio.service`);
console.error(`Alternatively export the variables first:`);
console.error(` export DISPLAY=":0" && export WAYLAND_DISPLAY="wayland-0" && touchkio\n`);
}
/** /**
* This promise resolves when the app has finished initializing, * This promise resolves when the app has finished initializing,
* allowing to safely create browser windows and perform other * allowing to safely create browser windows and perform other
@@ -24,6 +34,12 @@ app.whenReady().then(async () => {
let argsFilePath = path.join(app.getPath("userData"), "Arguments.json"); let argsFilePath = path.join(app.getPath("userData"), "Arguments.json");
let argsFileExists = fs.existsSync(argsFilePath); let argsFileExists = fs.existsSync(argsFilePath);
// Show version and release info
if ("help" in args || "version" in args) {
console.log(`${app.getName()}-v${app.getVersion()}, https://github.com/leukipp/touchkio`);
return app.quit();
}
// Setup arguments from file path // Setup arguments from file path
if ("setup" in args || (!argsProvided && !argsFileExists)) { if ("setup" in args || (!argsProvided && !argsFileExists)) {
await sleep(3000); await sleep(3000);
@@ -35,14 +51,20 @@ app.whenReady().then(async () => {
args = readArgs(argsFilePath); args = readArgs(argsFilePath);
} }
// Split url arguments
args.web_url = args.web_url || [];
if (!Array.isArray(args.web_url)) {
args.web_url = args.web_url.split(",").map((url) => url.trim());
}
global.ARGS = args;
// Show used arguments // Show used arguments
const json = JSON.stringify(args, null, 2); console.log(`Arguments: ${JSON.stringify(global.ARGS, null, 2)}\n`);
console.log(`Arguments: ${json}`);
// Chained init functions // Chained init functions
const chained = [webview.init, hardware.init, integration.init]; const chained = [webview.init, hardware.init, integration.init];
for (const init of chained) { for (const init of chained) {
if (!(await init(args))) { if (!(await init())) {
break; break;
} }
} }
@@ -55,14 +77,13 @@ app.whenReady().then(async () => {
* @returns {Object} An object mapping argument names to their corresponding values. * @returns {Object} An object mapping argument names to their corresponding values.
*/ */
const parseArgs = (proc) => { const parseArgs = (proc) => {
let args = {}; const args = proc.argv.slice(1).filter((arg) => arg !== ".");
proc.argv.slice(1).forEach((arg) => { return Object.fromEntries(
if (arg !== ".") { args.flatMap((arg) => {
const [key, value] = arg.split("="); const match = arg.match(/^--?([^=]+)(?:=(.*))?$/);
args[key.replace("--", "").replace("-", "_")] = value; return match ? [[match[1].replace(/-/g, "_"), match[2] ?? null]] : [];
} }),
}); );
return args;
}; };
/** /**
@@ -94,6 +115,11 @@ const promptArgs = async (proc) => {
question: "Enter WEB zoom level", question: "Enter WEB zoom level",
fallback: "1.25", fallback: "1.25",
}, },
{
key: "web_widget",
question: "Enter WEB widget enabled",
fallback: "true",
},
{ {
key: "mqtt", key: "mqtt",
question: "\nConnect to MQTT Broker?", question: "\nConnect to MQTT Broker?",
@@ -135,12 +161,7 @@ const promptArgs = async (proc) => {
const answer = await read.question(prompt); const answer = await read.question(prompt);
const value = (answer.trim() || fallback.match(/[YN]/)[0]).toLowerCase(); const value = (answer.trim() || fallback.match(/[YN]/)[0]).toLowerCase();
if (!["y", "yes"].includes(value)) { if (!["y", "yes"].includes(value)) {
ignore = ignore.concat([ ignore = ignore.concat(["mqtt_url", "mqtt_user", "mqtt_password", "mqtt_discovery"]);
"mqtt_url",
"mqtt_user",
"mqtt_password",
"mqtt_discovery",
]);
} }
} else if (key === "check") { } else if (key === "check") {
const json = JSON.stringify(args, null, 2); const json = JSON.stringify(args, null, 2);
@@ -154,7 +175,11 @@ const promptArgs = async (proc) => {
const prompt = `${question} (${fallback}): `; const prompt = `${question} (${fallback}): `;
const answer = await read.question(prompt); const answer = await read.question(prompt);
const value = answer.trim() || fallback; const value = answer.trim() || fallback;
args[key] = value; if (key === "web_url") {
args[key] = value.split(",").map((v) => v.trim());
} else {
args[key] = value;
}
} }
} }
read.close(); read.close();
@@ -228,4 +253,6 @@ const decrypt = (value) => {
* @param {number} ms - Sleep time in milliseconds. * @param {number} ms - Sleep time in milliseconds.
* @returns {Promise} A promise resolving after the timeout. * @returns {Promise} A promise resolving after the timeout.
*/ */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const sleep = (ms) => {
return new Promise((r) => setTimeout(r, ms));
};
+73 -17
View File
@@ -1,12 +1,39 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Determine system architecture
echo -e "Determining system architecture..."
BITS=$(getconf LONG_BIT)
case "$(uname -m)" in
aarch64)
ARCH="arm64"
;;
x86_64)
ARCH="x64"
;;
*)
echo "Architecture $(uname -m) running $BITS-bit operating system is not supported."
exit 1
;;
esac
if [ "$BITS" -ne 64 ]; then
echo "Architecture $ARCH running $BITS-bit operating system is not supported."
exit 1
fi
echo "Architecture $ARCH running $BITS-bit operating system is supported."
# Download the latest .deb package # Download the latest .deb package
echo -e "Downloading the latest release..." echo -e "\nDownloading the latest release..."
DEB_URL=$(wget -qO- https://api.github.com/repos/leukipp/touchkio/releases/latest | \ DEB_URL=$(wget -qO- https://api.github.com/repos/leukipp/touchkio/releases/latest | \
grep -o '"browser_download_url": "[^"]*\.deb"' | \ grep -o "\"browser_download_url\": \"[^\"]*_${ARCH}\.deb\"" | \
sed 's/"browser_download_url": "//;s/"//g') sed 's/"browser_download_url": "//;s/"//g')
DEB_PATH="/tmp/$(basename "$DEB_URL")"
TMP_DIR=$(mktemp -d)
DEB_PATH="${TMP_DIR}/$(basename "$DEB_URL")"
chmod 755 "$TMP_DIR"
if [ -z "$DEB_URL" ]; then if [ -z "$DEB_URL" ]; then
echo "Download url for .deb file not found." echo "Download url for .deb file not found."
@@ -21,7 +48,12 @@ fi
# Install the latest .deb package # Install the latest .deb package
echo -e "\nInstalling the latest release..." echo -e "\nInstalling the latest release..."
if ! sudo dpkg -i "$DEB_PATH"; then if ! command -v apt &> /dev/null; then
echo "Package manager apt was not found."
exit 1
fi
if ! sudo apt install "$DEB_PATH"; then
echo "Installation of .deb file failed." echo "Installation of .deb file failed."
exit 1 exit 1
fi fi
@@ -31,11 +63,10 @@ echo -e "\nCreating systemd user service..."
SERVICE_FILE="$HOME/.config/systemd/user/touchkio.service" SERVICE_FILE="$HOME/.config/systemd/user/touchkio.service"
mkdir -p "$(dirname "$SERVICE_FILE")" || { echo "Failed to create directory for $SERVICE_FILE"; exit 1; } mkdir -p "$(dirname "$SERVICE_FILE")" || { echo "Failed to create directory for $SERVICE_FILE"; exit 1; }
SERVICE_CONTENT="[Unit]
bash -c "cat << EOF > \"$SERVICE_FILE\"
[Unit]
Description=TouchKio Description=TouchKio
After=graphical.target After=graphical.target
Wants=network-online.target
[Service] [Service]
ExecStart=/usr/bin/touchkio ExecStart=/usr/bin/touchkio
@@ -43,22 +74,47 @@ Restart=on-failure
RestartSec=5s RestartSec=5s
[Install] [Install]
WantedBy=default.target WantedBy=default.target"
EOF"
systemctl --user enable "$(basename "$SERVICE_FILE")" || { echo "Failed to enable service $SERVICE_FILE"; exit 1; } SERVICE_CREATE=true
if [ -f "$SERVICE_FILE" ]; then
read -p "Service $SERVICE_FILE exists, overwrite? (y/N) " overwrite
if [[ ! ${overwrite:-n} == [Yy]* ]]; then
SERVICE_CREATE=false
fi
fi
# Create the udev rule for backlight if [ "$SERVICE_CREATE" = true ]; then
echo -e "\nCreating udev rule for backlight..." echo "$SERVICE_CONTENT" > "$SERVICE_FILE" || { echo "Failed to write to $SERVICE_FILE"; exit 1; }
systemctl --user enable "$(basename "$SERVICE_FILE")" || { echo "Failed to enable service $SERVICE_FILE"; exit 1; }
echo "Service $SERVICE_FILE enabled"
else
echo "Service $SERVICE_FILE not created"
fi
sudo bash -c 'cat << EOF > /etc/udev/rules.d/backlight-permissions.rules # Export display variables
SUBSYSTEM=="backlight", KERNEL=="10-0045", RUN+="/bin/chmod 666 /sys/class/backlight/%k/bl_power /sys/class/backlight/%k/brightness" echo -e "\nExporting display variables..."
EOF'
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 # Start the setup mode
echo "" echo ""
read -p "Start touchkio setup? (Y/n) " answer read -p "Start touchkio setup? (Y/n) " setup
if [[ ${answer:-y} == [Yy]* ]]; then if [[ ${setup:-y} == [Yy]* ]]; then
echo "/usr/bin/touchkio --setup" echo "/usr/bin/touchkio --setup"
/usr/bin/touchkio --setup /usr/bin/touchkio --setup
else
echo "/usr/bin/touchkio"
/usr/bin/touchkio
fi fi
+858
View File
@@ -0,0 +1,858 @@
const os = require("os");
const fs = require("fs");
const path = require("path");
const fsp = require("fs/promises");
const cpr = require("child_process");
const Events = require("events");
global.HARDWARE = global.HARDWARE || {
initialized: false,
events: new Events(),
session: {},
support: {},
battery: {
level: {
path: null,
},
},
display: {
status: {
value: null,
path: null,
command: null,
},
brightness: {
value: null,
path: null,
max: null,
},
},
keyboard: {
visible: null,
},
};
/**
* Initializes the hardware with the provided arguments.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const init = async () => {
if (!compatibleSystem()) {
console.warn("Operating system is not supported\n");
return false;
}
if (!sessionRights()) {
console.warn(`User "${sessionUser()}" is missing password-less sudo rights\n`);
return false;
}
// Init globals
HARDWARE.session.user = sessionUser();
HARDWARE.session.type = sessionType();
HARDWARE.session.desktop = sessionDesktop();
HARDWARE.battery.level.path = getBatteryLevelPath();
HARDWARE.display.status.path = getDisplayStatusPath();
HARDWARE.display.status.command = getDisplayStatusCommand();
HARDWARE.display.brightness.path = getDisplayBrightnessPath();
HARDWARE.display.brightness.max = getDisplayBrightnessMax();
HARDWARE.support = checkSupport();
HARDWARE.initialized = true;
// Show supported features
console.log(`Supported: ${JSON.stringify(HARDWARE.support, null, 2)}`);
// Show session infos
console.log("\nUser:", HARDWARE.session.user);
console.log("Session:", HARDWARE.session.type);
console.log("Desktop:", HARDWARE.session.desktop);
// Show device infos
console.log("\nModel:", getModel());
console.log("Vendor:", getVendor());
console.log("Serial Number:", getSerialNumber());
console.log("Host Name:", getHostName());
// Show system infos
console.log("\nUp Time:", getUpTime());
console.log("Memory Size:", getMemorySize());
console.log("Memory Usage:", getMemoryUsage());
console.log("Processor Usage:", getProcessorUsage());
console.log("Processor Temperature:", getProcessorTemperature());
// Show hardware infos
const unsupported = "unsupported";
console.log(
`\nBattery Level [${HARDWARE.battery.level.path || unsupported}]:`,
`${getBatteryLevel() || unsupported}`,
);
console.log(
`Display Status [${HARDWARE.display.status.path || unsupported}]:`,
`${getDisplayStatus() || unsupported} (${HARDWARE.display.status.command || unsupported})`,
);
console.log(
`Display Brightness [${HARDWARE.display.brightness.path || unsupported}]:`,
`${getDisplayBrightness() || unsupported}`,
);
console.log(
`Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "squeekboard" : unsupported}]:`,
`${getKeyboardVisibility() || unsupported}\n`,
);
// Check for keyboard visibility
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.events.emit("updateKeyboard");
});
});
// Check for display changes
interval(update, 1000);
return true;
};
/**
* Updates the shared hardware properties.
*/
const update = async () => {
if (!HARDWARE.initialized) {
return;
}
// Display status has changed
if (HARDWARE.support.displayStatus) {
const status = (await fsp.readFile(`${HARDWARE.display.status.path}/dpms`, "utf8")).trim();
if (status !== HARDWARE.display.status.value) {
console.log("Update Display Status:", getDisplayStatus());
HARDWARE.display.status.value = status;
HARDWARE.events.emit("updateDisplay");
}
}
// Display brightness has changed
if (HARDWARE.support.displayBrightness) {
const brightness = (await fsp.readFile(`${HARDWARE.display.brightness.path}/brightness`, "utf8")).trim();
if (brightness !== HARDWARE.display.brightness.value) {
console.log("Update Display Brightness:", getDisplayBrightness());
HARDWARE.display.brightness.value = brightness;
HARDWARE.events.emit("updateDisplay");
}
}
};
/**
* Verifies system compatibility by checking the presence of necessary sys paths.
*
* @returns {bool} Returns true if all paths exists.
*/
const compatibleSystem = () => {
if (os.platform() !== "linux") {
return false;
}
const paths = ["/sys/class/drm", "/sys/class/backlight", "/sys/class/power_supply", "/sys/class/thermal"];
return paths.every((path) => fs.existsSync(path));
};
/**
* Verifies user rights to run sudo commands without a password.
*
* @returns {bool} Returns true if password-less sudo rights exists.
*/
const sessionRights = () => {
if (!commandExists("sudo")) {
return false;
}
const result = execSyncCommand("sudo", ["-n", "true"]);
return result !== null && !result.includes("password is required");
};
/**
* Gets the session user name using `os.userInfo()`.
*
* @returns {string|null} Returns session user name or null if an error occurs.
*/
const sessionUser = () => {
try {
return os.userInfo().username;
} catch {}
return null;
};
/**
* Gets the session type for the user using `loginctl`.
*
* @returns {string|null} 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)",
"-p Type --value",
]);
};
/**
* Gets the desktop environment name by checking environment variables.
*
* @returns {string} Returns desktop environment name or 'unknown' if not detected.
*/
const sessionDesktop = () => {
const envs = ["XDG_CURRENT_DESKTOP", "XDG_DESKTOP_SESSION", "DESKTOP_SESSION"];
const names = envs.map((env) => process.env[env]).filter(Boolean);
return (names.join(":") || "unknown").toLowerCase();
};
/**
* Checks supported features based on global properties.
*
* @returns {Object} Returns support object with boolean values.
*/
const checkSupport = () => {
const battery = HARDWARE.battery.level;
const status = HARDWARE.display.status;
const brightness = HARDWARE.display.brightness;
const keyboard = processRuns("squeekboard");
return {
batteryLevel: battery.path !== null,
displayStatus: status.path !== null && status.command !== null,
displayBrightness: brightness.path !== null && brightness.max !== null,
keyboardVisibility: keyboard === true,
};
};
/**
* Gets the model name using `/sys/firmware/devicetree/base/model` or `/sys/class/dmi/id/product_name`.
*
* @returns {string} 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"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "Generic";
}
}
return "Generic";
};
/**
* Gets the vendor name using `/sys/class/dmi/id/board_vendor`.
*
* @returns {string} The vendor name of the device or 'Generic' if not found.
*/
const getVendor = () => {
const paths = ["/sys/class/dmi/id/board_vendor"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "Generic";
}
}
const model = getModel();
if (model.includes("Raspberry Pi")) {
return "Raspberry Pi Ltd";
}
return "Generic";
};
/**
* Gets the serial number using `/sys/firmware/devicetree/base/serial-number` or `/sys/class/dmi/id/product_serial`.
*
* @returns {string} 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"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "123456";
}
}
return "123456";
};
/**
* Gets the host machine id using `/etc/machine-id`.
*
* @returns {string} The machine id of the system or '123456' if not found.
*/
const getMachineId = () => {
const paths = ["/etc/machine-id"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "123456";
}
}
return "123456";
};
/**
* Gets the host name of the current system using `os.hostname()`.
*
* @returns {string} The host name of the system.
*/
const getHostName = () => {
return os.hostname();
};
/**
* Gets the up time of the system in minutes using `os.uptime()`.
*
* @returns {number} The up time of the system in minutes.
*/
const getUpTime = () => {
return os.uptime() / 60;
};
/**
* Gets the total available memory in gibibytes using `os.totalmem()`.
*
* @returns {number} The total available memory in GiB.
*/
const getMemorySize = () => {
return os.totalmem() / 1024 ** 3;
};
/**
* Gets the current memory usage as a percentage using `os.totalmem()` and `os.freemem()`.
*
* @returns {number} The percentage of used memory.
*/
const getMemoryUsage = () => {
return ((os.totalmem() - os.freemem()) / os.totalmem()) * 100;
};
/**
* Gets the CPU load average over the last 5 minutes as a percentage using `os.loadavg()` and `os.cpus()`.
*
* @returns {number} The CPU load average percentage over the last 5 minutes.
*/
const getProcessorUsage = () => {
return (os.loadavg()[1] / os.cpus().length) * 100;
};
/**
* Gets the current CPU temperature using `/sys/class/thermal`.
*
* @returns {number|null} The CPU temperature in degrees celsius or null if nothing was found.
*/
const getProcessorTemperature = () => {
const thermal = "/sys/class/thermal";
for (const zone of fs.readdirSync(thermal)) {
const typeFile = path.join(thermal, zone, "type");
const tempFile = path.join(thermal, zone, "temp");
if (!fs.existsSync(typeFile) || !fs.existsSync(tempFile)) {
continue;
}
const type = fs.readFileSync(typeFile, "utf8").trim();
if (["cpu-thermal", "x86_pkg_temp", "k10temp", "acpitz"].includes(type)) {
return parseFloat(fs.readFileSync(tempFile, "utf8").trim()) / 1000;
}
}
return null;
};
/**
* Gets the current battery power level path using `/sys/class/power_supply`.
*
* @returns {string|null} The battery power level path or null if nothing was found.
*/
const getBatteryLevelPath = () => {
const power = "/sys/class/power_supply";
for (const supply of fs.readdirSync(power)) {
const capacityFile = path.join(power, supply, "capacity");
if (!fs.existsSync(capacityFile)) {
continue;
}
return path.join(power, supply);
}
return null;
};
/**
* Gets the current battery power level using `/sys/class/power_supply/.../capacity`.
*
* @returns {number|null} The battery power level in percentage or null if nothing was found.
*/
const getBatteryLevel = () => {
if (!HARDWARE.support.batteryLevel) {
return null;
}
const capacity = fs.readFileSync(`${HARDWARE.battery.level.path}/capacity`, "utf8").trim();
if (capacity) {
return parseFloat(capacity);
}
return null;
};
/**
* Gets the current display status path using `/sys/class/drm`.
*
* @returns {string|null} The display status path or null if nothing was found.
*/
const getDisplayStatusPath = () => {
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 (content === "connected") {
return path.join(drm, card);
}
}
return null;
};
/**
* Gets the available display status command checking for `wlopm`, `kscreen-doctor` and `xset`.
*
* @returns {string|null} The display status command or null if nothing was found.
*/
const getDisplayStatusCommand = () => {
const type = HARDWARE.session.type;
const desktop = HARDWARE.session.desktop;
const mapping = {
wayland: [
{ command: "wlopm", desktops: ["labwc", "wayfire", "unknown"] },
{ command: "kscreen-doctor", desktops: ["kde", "plasma", "unknown"] },
],
x11: [{ command: "xset", desktops: ["*"] }],
}[type];
for (const map of mapping || []) {
if (commandExists(map.command) && map.desktops.some((d) => d === "*" || desktop.includes(d))) {
return map.command;
}
}
console.warn("No display command available");
return null;
};
/**
* Gets the current display power status using the available command.
*
* @returns {string|null} The display status as 'ON'/'OFF' or null if an error occurs.
*/
const getDisplayStatus = () => {
if (!HARDWARE.support.displayStatus) {
return null;
}
switch (HARDWARE.display.status.command) {
case "wlopm":
const wlopm = execSyncCommand("wlopm", []);
if (wlopm !== null) {
const out = wlopm.split("\n")[0].split(" ");
return out.pop().toUpperCase();
}
break;
case "kscreen-doctor":
const kdoc = execSyncCommand("kscreen-doctor", ["--dpms", "show"]);
if (kdoc !== null) {
const out = kdoc.split("\n")[0].split(" ");
return out.pop().toUpperCase();
}
break;
case "xset":
const xset = execSyncCommand("xset", ["-q"]);
if (xset !== null) {
const on = /Monitor is On/.test(xset);
return on ? "ON" : "OFF";
}
break;
}
return null;
};
/**
* Sets the display power status using the available command.
*
* This function takes a desired status ('ON' or 'OFF') and executes
* the appropriate command to set the display status.
*
* @param {string} status - The desired status ('ON' or 'OFF').
* @param {Function} callback - A callback function that receives the output or error.
*/
const setDisplayStatus = (status, callback = null) => {
if (!HARDWARE.support.displayStatus) {
if (typeof callback === "function") callback(null, "Not supported");
return;
}
if (!["ON", "OFF"].includes(status)) {
console.error("Status must be 'ON' or 'OFF'");
if (typeof callback === "function") callback(null, "Invalid status");
return;
}
switch (HARDWARE.display.status.command) {
case "wlopm":
execAsyncCommand("wlopm", [`--${status.toLowerCase()}`, "*"], callback);
break;
case "kscreen-doctor":
execAsyncCommand("kscreen-doctor", ["--dpms", status.toLowerCase()], callback);
break;
case "xset":
execAsyncCommand("xset", ["dpms", "force", status.toLowerCase()], callback);
break;
}
};
/**
* Gets the current display brightness path using `/sys/class/backlight`.
*
* @returns {string|null} The display brightness path or null if nothing was found.
*/
const getDisplayBrightnessPath = () => {
const backlight = "/sys/class/backlight";
for (const address of fs.readdirSync(backlight)) {
const brightnessFile = path.join(backlight, address, "brightness");
if (!fs.existsSync(brightnessFile)) {
continue;
}
return path.join(backlight, address);
}
return null;
};
/**
* Gets the maximum display brightness value using `/sys/class/backlight/.../max_brightness`.
*
* @returns {number|null} The brightness maximum value or null if an error occurs.
*/
const getDisplayBrightnessMax = () => {
if (!HARDWARE.display.brightness.path) {
return null;
}
const max = fs.readFileSync(`${HARDWARE.display.brightness.path}/max_brightness`, "utf8").trim();
if (max) {
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.support.displayBrightness) {
return null;
}
const brightness = fs.readFileSync(`${HARDWARE.display.brightness.path}/brightness`, "utf8").trim();
if (brightness) {
const max = HARDWARE.display.brightness.max || 1;
return Math.max(1, Math.min(Math.round((parseInt(brightness, 10) / max) * 100), 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 {Function} callback - A callback function that receives the output or error.
*/
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) {
console.error("Brightness must be a number between 1 and 100");
if (typeof callback === "function") callback(null, "Invalid brightness");
return;
}
const max = HARDWARE.display.brightness.max || 1;
const value = Math.max(1, Math.min(Math.round((brightness / 100) * max), max));
const proc = execAsyncCommand("sudo", ["tee", `${HARDWARE.display.brightness.path}/brightness`], callback);
proc.stdin.write(value.toString());
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 {bool} 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;
}
if (!["ON", "OFF"].includes(visibility)) {
console.error("Visibility must be 'ON' or 'OFF'");
if (typeof callback === "function") callback(null, "Invalid visibility");
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<string>} 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`.
*
* This function executes the command asynchronously.
* The output of the command will be provided through the callback function.
*
* @param {Function} callback - A callback function that receives the output or error.
*/
const shutdownSystem = (callback = null) => {
execAsyncCommand("sudo", ["shutdown", "-h", "now"], callback);
};
/**
* Reboots the system using `sudo reboot`.
*
* This function executes the command asynchronously.
* The output of the command will be provided through the callback function.
*
* @param {Function} callback - A callback function that receives the output or error.
*/
const rebootSystem = (callback = null) => {
execAsyncCommand("sudo", ["reboot"], callback);
};
/**
* Checks if a process is running using `pidof`.
*
* @param {string} name - The process name to check.
* @returns {bool} Returns true if the process runs.
*/
const processRuns = (name) => {
try {
return !!cpr.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 {bool} Returns true if the command is available.
*/
const commandExists = (name) => {
try {
return !!cpr.execSync(`which ${name}`, { encoding: "utf8" });
} catch {}
return false;
};
/**
* Executes a command synchronously and returns the output.
*
* @param {string} cmd - The command to execute.
* @param {Array<string>} args - The arguments for the command.
* @returns {string|null} The output of the command or null if an error occurs.
*/
const execSyncCommand = (cmd, args = []) => {
try {
const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" });
return output.trim().replace(/\0/g, "");
} catch (error) {
console.error("Execute Sync:", error.message);
}
return null;
};
/**
* Executes a command asynchronously.
*
* @param {string} cmd - The command to execute.
* @param {Array<string>} args - The arguments for the command.
* @param {Function} callback - A callback function that receives the output or error.
* @returns {Object} The spawned process object.
*/
const execAsyncCommand = (cmd, args = [], callback = null) => {
let errorOutput = "";
let successOutput = "";
let proc = cpr.spawn(cmd, args);
proc.stderr.on("data", (data) => {
if (data) {
errorOutput += data.toString();
}
});
proc.stdout.on("data", (data) => {
if (data) {
successOutput += data.toString();
}
});
proc.on("close", (code) => {
try {
if (typeof callback === "function") {
if (code === 0) {
callback(successOutput.trim().replace(/\0/g, ""), null);
} else {
callback(null, errorOutput.trim().replace(/\0/g, ""));
}
}
} 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<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, "").split("\n").slice(1).join("\n").trim(), 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 = cpr.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;
};
/**
* Helper function for asynchronous interval calls.
*
* @param {Function} callback - An async callback function.
* @param {number} ms - Sleep time in milliseconds.
*/
const interval = (callback, ms) => {
const run = () => {
setTimeout(async () => {
try {
await callback();
} catch (error) {
console.error("Interval Callback:", error.message);
}
run();
}, ms);
};
run();
};
module.exports = {
init,
update,
getModel,
getVendor,
getSerialNumber,
getMachineId,
getHostName,
getUpTime,
getMemorySize,
getMemoryUsage,
getProcessorUsage,
getProcessorTemperature,
getBatteryLevel,
getDisplayStatus,
setDisplayStatus,
getDisplayBrightness,
setDisplayBrightness,
getKeyboardVisibility,
setKeyboardVisibility,
checkPackageUpgrades,
shutdownSystem,
rebootSystem,
};
+863
View File
@@ -0,0 +1,863 @@
const mqtt = require("mqtt");
const hardware = require("./hardware");
const { app } = require("electron");
const Events = require("events");
global.INTEGRATION = global.INTEGRATION || {
initialized: false,
events: new Events(),
};
/**
* Initializes the integration with the provided arguments.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const init = async () => {
if (!ARGS.mqtt_url) {
return false;
}
if (!/^mqtts?:\/\//.test(ARGS.mqtt_url)) {
console.error("Please provide the '--mqtt-url' parameter with mqtt(s)");
return app.quit();
}
// Parse arguments
const url = new URL(ARGS.mqtt_url);
const user = ARGS.mqtt_user ? ARGS.mqtt_user : null;
const password = ARGS.mqtt_password ? ARGS.mqtt_password : null;
const discovery = ARGS.mqtt_discovery ? ARGS.mqtt_discovery : "homeassistant";
const model = hardware.getModel();
const vendor = hardware.getVendor();
const hostName = hardware.getHostName();
const serialNumber = hardware.getSerialNumber();
const serialNumberSuffix = serialNumber.slice(-6);
const deviceName = hostName.charAt(0).toUpperCase() + hostName.slice(1);
const deviceId = serialNumberSuffix.toUpperCase().replace(/[^A-Z0-9]/g, "");
// Init globals
INTEGRATION.discovery = discovery;
INTEGRATION.node = `rpi_${deviceId}`;
INTEGRATION.root = `${app.getName()}/${INTEGRATION.node}`;
INTEGRATION.device = {
name: `TouchKio ${deviceName}`,
model: model,
manufacturer: vendor,
serial_number: serialNumber,
identifiers: [INTEGRATION.node],
sw_version: `${app.getName()}-v${app.getVersion()}`,
};
// Connection settings
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);
// Client connected
INTEGRATION.client
.once("connect", () => {
console.log(`MQTT Connected: ${url.toString()}\n`);
// Init client controls
initShutdown();
initReboot();
initRefresh();
initKiosk();
initDisplay();
initKeyboard();
initPageNumber();
initPageZoom();
initPageUrl();
// Init client sensors
initModel();
initSerialNumber();
initHostName();
initUpTime();
initMemorySize();
initMemoryUsage();
initProcessorUsage();
initProcessorTemperature();
initBatteryLevel();
initPackageUpgrades();
initHeartbeat();
initLastActive();
// Integration initialized
INTEGRATION.initialized = true;
})
.on("error", (error) => {
console.error("MQTT", error.message);
});
// Update sensor states from events
HARDWARE.events.on("updateDisplay", updateDisplay);
HARDWARE.events.on("updateKeyboard", updateKeyboard);
// Update time sensors periodically (30s)
setInterval(() => {
updateHeartbeat();
updateLastActive();
}, 30 * 1000);
// Update system sensors periodically (1min)
setInterval(() => {
update();
}, 60 * 1000);
// Update package sensors periodically (60min)
setInterval(() => {
updatePackageUpgrades();
}, 3600 * 1000);
return true;
};
/**
* Updates the shared integration properties.
*/
const update = async () => {
if (!INTEGRATION.initialized) {
return;
}
// Update client sensors
updateKiosk();
updatePageNumber();
updatePageZoom();
updatePageUrl();
updateUpTime();
updateLastActive();
updateMemoryUsage();
updateProcessorUsage();
updateProcessorTemperature();
updateBatteryLevel();
};
/**
* Publishes the auto-discovery config via the mqtt connection.
*
* @param {string} type - The entity type name.
* @param {Object} config - The configuration object.
* @returns {Object} Instance of the mqtt client.
*/
const publishConfig = (type, config) => {
if (type === null || config === null) {
return INTEGRATION.client;
}
const path = config.unique_id.replace(`${INTEGRATION.node}_`, "");
const root = `${INTEGRATION.discovery}/${type}/${INTEGRATION.node}/${path}/config`;
return INTEGRATION.client.publish(root, JSON.stringify(config), { qos: 1, retain: true });
};
/**
* Publishes the sensor attributes via the mqtt connection.
*
* @param {string} path - The entity path name.
* @param {Object} attributes - The attributes object.
* @returns {Object} Instance of the mqtt client.
*/
const publishAttributes = (path, attributes) => {
if (path === null || attributes === null) {
return INTEGRATION.client;
}
const root = `${INTEGRATION.root}/${path}/attributes`;
return INTEGRATION.client.publish(root, JSON.stringify(attributes), { qos: 1, retain: true });
};
/**
* Publishes the sensor state via the mqtt connection.
*
* @param {string} path - The entity path name.
* @param {string|number} state - The state value.
* @returns {Object} Instance of the mqtt client.
*/
const publishState = (path, state) => {
if (path === null || state === null) {
return INTEGRATION.client;
}
const root = `${INTEGRATION.root}/${path}/state`;
return INTEGRATION.client.publish(root, `${state}`, { qos: 1, retain: true });
};
/**
* Initializes the shutdown button and handles the execute logic.
*/
const initShutdown = () => {
const root = `${INTEGRATION.root}/shutdown`;
const config = {
name: "Shutdown",
unique_id: `${INTEGRATION.node}_shutdown`,
command_topic: `${root}/execute`,
icon: "mdi:power",
device: INTEGRATION.device,
};
publishConfig("button", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Shutdown system...");
hardware.setDisplayStatus("ON");
hardware.shutdownSystem();
}
})
.subscribe(config.command_topic);
};
/**
* Initializes the reboot button and handles the execute logic.
*/
const initReboot = () => {
const root = `${INTEGRATION.root}/reboot`;
const config = {
name: "Reboot",
unique_id: `${INTEGRATION.node}_reboot`,
command_topic: `${root}/execute`,
icon: "mdi:restart",
device: INTEGRATION.device,
};
publishConfig("button", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Rebooting system...");
hardware.setDisplayStatus("ON");
hardware.rebootSystem();
}
})
.subscribe(config.command_topic);
};
/**
* Initializes the refresh button and handles the execute logic.
*/
const initRefresh = () => {
const root = `${INTEGRATION.root}/refresh`;
const config = {
name: "Refresh",
unique_id: `${INTEGRATION.node}_refresh`,
command_topic: `${root}/execute`,
icon: "mdi:web-refresh",
device: INTEGRATION.device,
};
publishConfig("button", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
console.log("Refreshing webview...");
hardware.setDisplayStatus("ON");
WEBVIEW.events.emit("reloadView");
}
})
.subscribe(config.command_topic);
};
/**
* Initializes the kiosk select status and handles the execute logic.
*/
const initKiosk = () => {
const root = `${INTEGRATION.root}/kiosk`;
const config = {
name: "Kiosk",
unique_id: `${INTEGRATION.node}_kiosk`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
options: ["Framed", "Fullscreen", "Maximized", "Minimized", "Terminated"],
icon: "mdi:overscan",
device: INTEGRATION.device,
};
publishConfig("select", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const status = message.toString();
console.log("Set Kiosk Status:", status);
hardware.setDisplayStatus("ON");
switch (status) {
case "Framed":
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(false);
break;
case "Fullscreen":
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
break;
case "Maximized":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.maximize();
break;
case "Minimized":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.minimize();
break;
case "Terminated":
app.quit();
}
}
})
.subscribe(config.command_topic);
updateKiosk();
};
/**
* Updates the kiosk status via the mqtt connection.
*/
const updateKiosk = () => {
const kiosk = WEBVIEW.status;
publishState("kiosk", kiosk);
};
/**
* Initializes the display status, brightness and handles the execute logic.
*/
const initDisplay = () => {
if (!HARDWARE.support.displayStatus) {
return;
}
const root = `${INTEGRATION.root}/display`;
const config = {
name: "Display",
unique_id: `${INTEGRATION.node}_display`,
command_topic: `${root}/power/set`,
state_topic: `${root}/power/state`,
icon: "mdi:monitor-shimmer",
platform: "light",
device: INTEGRATION.device,
...(HARDWARE.support.displayBrightness && {
brightness_command_topic: `${root}/brightness/set`,
brightness_state_topic: `${root}/brightness/state`,
brightness_scale: 100,
}),
};
publishConfig("light", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const status = message.toString();
console.log("Set Display Status:", status);
hardware.setDisplayStatus(status, (reply, error) => {
if (error) {
console.warn("Failed:", error);
} else {
hardware.update();
}
});
} else if (topic === config.brightness_command_topic) {
const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness, (reply, error) => {
if (error) {
console.warn("Failed:", error);
} else {
hardware.update();
}
});
}
})
.subscribe(config.command_topic)
.subscribe(config.brightness_command_topic);
updateDisplay();
};
/**
* Updates the display status, brightness via the mqtt connection.
*/
const updateDisplay = () => {
const status = hardware.getDisplayStatus();
const brightness = hardware.getDisplayBrightness();
publishState("display/power", status);
publishState("display/brightness", brightness);
};
/**
* Initializes the keyboard visibility and handles the execute logic.
*/
const initKeyboard = () => {
if (!HARDWARE.support.keyboardVisibility) {
return;
}
const root = `${INTEGRATION.root}/keyboard`;
const config = {
name: "Keyboard",
unique_id: `${INTEGRATION.node}_keyboard`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
icon: "mdi:keyboard-close-outline",
device: INTEGRATION.device,
};
publishConfig("switch", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
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(config.command_topic);
updateKeyboard();
};
/**
* Updates the keyboard visibility via the mqtt connection.
*/
const updateKeyboard = () => {
const visibility = hardware.getKeyboardVisibility();
publishState("keyboard", visibility);
};
/**
* Initializes the page number and handles the execute logic.
*/
const initPageNumber = () => {
if (WEBVIEW.viewUrls.length < 3) {
return;
}
const root = `${INTEGRATION.root}/page_number`;
const config = {
name: "Page Number",
unique_id: `${INTEGRATION.node}_page_number`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value | int }}",
mode: "box",
min: 1,
max: WEBVIEW.viewUrls.length - 1,
unit_of_measurement: "Page",
icon: "mdi:page-next",
device: INTEGRATION.device,
};
publishConfig("number", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const number = parseInt(message, 10);
console.log("Set Page Number:", number);
WEBVIEW.viewActive = number || 1;
WEBVIEW.events.emit("updateView");
}
})
.subscribe(config.command_topic);
updatePageNumber();
};
/**
* Updates the page number via the mqtt connection.
*/
const updatePageNumber = () => {
const pageNumber = WEBVIEW.viewActive || 1;
publishState("page_number", pageNumber);
};
/**
* Initializes the page zoom and handles the execute logic.
*/
const initPageZoom = () => {
const root = `${INTEGRATION.root}/page_zoom`;
const config = {
name: "Page Zoom",
unique_id: `${INTEGRATION.node}_page_zoom`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value | int }}",
mode: "slider",
min: 25,
max: 400,
unit_of_measurement: "%",
icon: "mdi:magnify-plus",
device: INTEGRATION.device,
};
publishConfig("number", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const zoom = parseInt(message, 10);
console.log("Set Page Zoom:", zoom);
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.setZoomFactor(zoom / 100.0);
WEBVIEW.events.emit("updateView");
}
})
.subscribe(config.command_topic);
updatePageZoom();
};
/**
* Updates the page zoom via the mqtt connection.
*/
const updatePageZoom = () => {
const pageZoom = Math.round(WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getZoomFactor() * 100.0);
publishState("page_zoom", pageZoom);
};
/**
* Initializes the page url and handles the execute logic.
*/
const initPageUrl = () => {
const root = `${INTEGRATION.root}/page_url`;
const config = {
name: "Page Url",
unique_id: `${INTEGRATION.node}_page_url`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
pattern: "https?://.*",
icon: "mdi:web",
device: INTEGRATION.device,
};
publishConfig("text", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const url = message.toString();
console.log("Set Page Url:", url);
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.loadURL(url);
}
})
.subscribe(config.command_topic);
updatePageUrl();
};
/**
* Updates the page url via the mqtt connection.
*/
const updatePageUrl = () => {
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive || 1];
const currentUrl = WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getURL();
const pageUrl = !currentUrl || currentUrl.startsWith("data:") ? defaultUrl : currentUrl;
publishState("page_url", pageUrl);
};
/**
* Initializes the model sensor.
*/
const initModel = () => {
const root = `${INTEGRATION.root}/model`;
const config = {
name: "Model",
unique_id: `${INTEGRATION.node}_model`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
icon: "mdi:raspberry-pi",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateModel();
};
/**
* Updates the model sensor via the mqtt connection.
*/
const updateModel = () => {
const model = hardware.getModel();
publishState("model", model);
};
/**
* Initializes the serial number sensor.
*/
const initSerialNumber = () => {
const root = `${INTEGRATION.root}/serial_number`;
const config = {
name: "Serial Number",
unique_id: `${INTEGRATION.node}_serial_number`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
icon: "mdi:hexadecimal",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateSerialNumber();
};
/**
* Updates the serial number sensor via the mqtt connection.
*/
const updateSerialNumber = () => {
const serialNumber = hardware.getSerialNumber();
publishState("serial_number", serialNumber);
};
/**
* Initializes the host name sensor.
*/
const initHostName = () => {
const root = `${INTEGRATION.root}/host_name`;
const config = {
name: "Host Name",
unique_id: `${INTEGRATION.node}_host_name`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
icon: "mdi:console-network",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateHostName();
};
/**
* Updates the host name sensor via the mqtt connection.
*/
const updateHostName = () => {
const hostName = hardware.getHostName();
publishState("host_name", hostName);
};
/**
* Initializes the up time sensor.
*/
const initUpTime = () => {
const root = `${INTEGRATION.root}/up_time`;
const config = {
name: "Up Time",
unique_id: `${INTEGRATION.node}_up_time`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min",
icon: "mdi:timeline-clock",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateUpTime();
};
/**
* Updates the up time sensor via the mqtt connection.
*/
const updateUpTime = () => {
const upTime = hardware.getUpTime();
publishState("up_time", upTime);
};
/**
* Initializes the memory size sensor.
*/
const initMemorySize = () => {
const root = `${INTEGRATION.root}/memory_size`;
const config = {
name: "Memory Size",
unique_id: `${INTEGRATION.node}_memory_size`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(2) }}",
unit_of_measurement: "GiB",
icon: "mdi:memory",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateMemorySize();
};
/**
* Updates the memory size sensor via the mqtt connection.
*/
const updateMemorySize = () => {
const memorySize = hardware.getMemorySize();
publishState("memory_size", memorySize);
};
/**
* Initializes the memory usage sensor.
*/
const initMemoryUsage = () => {
const root = `${INTEGRATION.root}/memory_usage`;
const config = {
name: "Memory Usage",
unique_id: `${INTEGRATION.node}_memory_usage`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "%",
icon: "mdi:memory-arrow-down",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateMemoryUsage();
};
/**
* Updates the memory usage sensor via the mqtt connection.
*/
const updateMemoryUsage = () => {
const memoryUsage = hardware.getMemoryUsage();
publishState("memory_usage", memoryUsage);
};
/**
* Initializes the processor usage sensor.
*/
const initProcessorUsage = () => {
const root = `${INTEGRATION.root}/processor_usage`;
const config = {
name: "Processor Usage",
unique_id: `${INTEGRATION.node}_processor_usage`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "%",
icon: "mdi:cpu-64-bit",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateProcessorUsage();
};
/**
* Updates the processor usage sensor via the mqtt connection.
*/
const updateProcessorUsage = () => {
const processorUsage = hardware.getProcessorUsage();
publishState("processor_usage", processorUsage);
};
/**
* Initializes the processor temperature sensor.
*/
const initProcessorTemperature = () => {
const root = `${INTEGRATION.root}/processor_temperature`;
const config = {
name: "Processor Temperature",
unique_id: `${INTEGRATION.node}_processor_temperature`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "°C",
icon: "mdi:radiator",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateProcessorTemperature();
};
/**
* Updates the processor temperature sensor via the mqtt connection.
*/
const updateProcessorTemperature = () => {
const processorTemperature = hardware.getProcessorTemperature();
publishState("processor_temperature", processorTemperature);
};
/**
* Initializes the battery level sensor.
*/
const initBatteryLevel = () => {
if (!HARDWARE.support.batteryLevel) {
return;
}
const root = `${INTEGRATION.root}/battery_level`;
const config = {
name: "Battery Level",
unique_id: `${INTEGRATION.node}_battery_level`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "%",
icon: "mdi:battery-medium",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateBatteryLevel();
};
/**
* Updates the battery level sensor via the mqtt connection.
*/
const updateBatteryLevel = () => {
const batteryLevel = hardware.getBatteryLevel();
publishState("battery_level", batteryLevel);
};
/**
* Initializes the package upgrades sensor.
*/
const initPackageUpgrades = () => {
const root = `${INTEGRATION.root}/package_upgrades`;
const config = {
name: "Package Upgrades",
unique_id: `${INTEGRATION.node}_package_upgrades`,
state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value | int }}",
icon: "mdi:package-down",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updatePackageUpgrades();
};
/**
* Updates the package upgrades sensor via the mqtt connection.
*/
const updatePackageUpgrades = () => {
const packages = hardware.checkPackageUpgrades();
const attributes = {
total: packages.length,
packages: packages.map((pkg) => pkg.replace(/\s*\[.*?\]\s*/g, "").trim()),
};
publishAttributes("package_upgrades", attributes);
publishState("package_upgrades", attributes.total);
};
/**
* Initializes the heartbeat sensor.
*/
const initHeartbeat = () => {
const root = `${INTEGRATION.root}/heartbeat`;
const config = {
name: "Heartbeat",
unique_id: `${INTEGRATION.node}_heartbeat`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
icon: "mdi:heart-flash",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateHeartbeat();
};
/**
* Updates the heartbeat sensor via the mqtt connection.
*/
const updateHeartbeat = () => {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, "");
publishState("heartbeat", heartbeat);
};
/**
* Initializes the last active sensor.
*/
const initLastActive = () => {
const root = `${INTEGRATION.root}/last_active`;
const config = {
name: "Last Active",
unique_id: `${INTEGRATION.node}_last_active`,
state_topic: `${root}/state`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min",
icon: "mdi:gesture-tap-hold",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateLastActive();
};
/**
* Updates the last active sensor via the mqtt connection.
*/
const updateLastActive = () => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const lastActive = Math.abs(now - then) / (1000 * 60);
publishState("last_active", lastActive);
};
module.exports = {
init,
update,
};
+912
View File
@@ -0,0 +1,912 @@
const path = require("path");
const axios = require("axios");
const hardware = require("./hardware");
const integration = require("./integration");
const { app, nativeTheme, globalShortcut, ipcMain, session, BaseWindow, WebContentsView } = require("electron");
const Events = require("events");
global.WEBVIEW = global.WEBVIEW || {
initialized: false,
events: new Events(),
status: null,
locked: false,
pointer: {
time: new Date(),
},
};
/**
* Initializes the webview with the provided arguments.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const init = async () => {
if (ARGS.web_url.length === 0) {
console.error("Please provide the '--web-url' parameter");
return app.quit();
}
if (ARGS.web_url.some((url) => !/^https?:\/\//.test(url))) {
console.error("Please provide the '--web-url' parameter with http(s)");
return app.quit();
}
session.defaultSession.clearCache();
// Parse arguments
const fullscreen = ARGS.app_debug !== "true";
const widget = ARGS.web_widget ? ARGS.web_widget === "true" : true;
const zoom = !isNaN(parseFloat(ARGS.web_zoom)) ? parseFloat(ARGS.web_zoom) : 1.25;
const theme = ["light", "dark"].includes(ARGS.web_theme) ? ARGS.web_theme : "dark";
const urls = [loaderHtml(40, 1.0, theme)].concat(ARGS.web_url);
// Init global properties
WEBVIEW.viewActive = 0;
WEBVIEW.viewUrls = urls;
WEBVIEW.viewZoom = zoom;
WEBVIEW.viewTheme = theme;
WEBVIEW.pagerEnabled = widget;
WEBVIEW.widgetTheme = theme;
WEBVIEW.widgetEnabled = widget;
WEBVIEW.navigationTheme = theme;
WEBVIEW.navigationEnabled = widget;
nativeTheme.themeSource = WEBVIEW.viewTheme;
WEBVIEW.events.on("reloadView", reloadView);
WEBVIEW.events.on("updateView", updateView);
// Init global root window
WEBVIEW.window = new BaseWindow({
title: "TouchKio",
icon: path.join(__dirname, "..", "img", "icon.png"),
});
WEBVIEW.window.setMenuBarVisibility(false);
WEBVIEW.window.setFullScreen(fullscreen);
if (!fullscreen) {
WEBVIEW.window.maximize();
}
// Init global webview
WEBVIEW.views = [];
urls.forEach((url, i) => {
const view = new WebContentsView({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
view.setVisible(i === 0);
view.setBackgroundColor("#FFFFFFFF");
WEBVIEW.window.contentView.addChildView(view);
WEBVIEW.views.push(view);
onlineStatus(url).then(() => {
view.webContents.loadURL(url);
});
});
// Init global pager
WEBVIEW.pager = new WebContentsView({
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
WEBVIEW.pager.setBackgroundColor("#00000000");
WEBVIEW.window.contentView.addChildView(WEBVIEW.pager);
WEBVIEW.pager.webContents.loadFile(path.join(app.getAppPath(), "html", "pager.html"));
// Init global widget
WEBVIEW.widget = new WebContentsView({
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
WEBVIEW.widget.setBackgroundColor("#00000000");
WEBVIEW.window.contentView.addChildView(WEBVIEW.widget);
WEBVIEW.widget.webContents.loadFile(path.join(app.getAppPath(), "html", "widget.html"));
// Init global navigation
WEBVIEW.navigation = new WebContentsView({
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
WEBVIEW.navigation.setBackgroundColor("#00000000");
WEBVIEW.window.contentView.addChildView(WEBVIEW.navigation);
WEBVIEW.navigation.webContents.loadFile(path.join(app.getAppPath(), "html", "navigation.html"));
// Register events
windowEvents();
widgetEvents();
navigationEvents();
viewEvents();
appEvents();
return true;
};
/**
* Updates the shared webview properties.
*/
const update = async () => {
if (!WEBVIEW.initialized) {
return;
}
// Update windows status
if (WEBVIEW.window.isFullScreen()) {
WEBVIEW.status = "Fullscreen";
} else if (WEBVIEW.window.isMinimized()) {
WEBVIEW.status = "Minimized";
} else if (WEBVIEW.window.isMaximized()) {
WEBVIEW.status = "Maximized";
} else {
WEBVIEW.status = "Framed";
}
// Update widget status
updateWidget();
// Update navigation status
updateNavigation();
// Update integration sensor
console.log("Update Kiosk Status:", WEBVIEW.status);
integration.update();
};
/**
* Updates the active view.
*/
const updateView = () => {
if (!WEBVIEW.viewActive) {
return;
}
const url = WEBVIEW.views[WEBVIEW.viewActive].webContents.getURL();
const host = url.startsWith("data:") ? "whoopsie" : new URL(url).host;
const title = `TouchKio - ${host} (${WEBVIEW.viewActive})`;
// Update window title
console.log(`Update View: ${title}`);
WEBVIEW.window.setTitle(title);
// Hide all other webviews and show only the active one
WEBVIEW.views.forEach((view, i) => {
view.setVisible(i === WEBVIEW.viewActive);
});
update();
};
/**
* Updates the widget control.
*/
const updateWidget = () => {
// Hide keyboard button
WEBVIEW.widget.webContents.send("button-hidden", {
id: "keyboard",
hidden: !HARDWARE.support.keyboardVisibility,
});
// Hide navigation button
WEBVIEW.widget.webContents.send("button-hidden", {
id: "navigation",
hidden: !WEBVIEW.navigationEnabled,
});
};
/**
* Updates the navigation control.
*/
const updateNavigation = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
// Hide pager buttons
WEBVIEW.navigation.webContents.send("button-hidden", {
id: "previous",
hidden: WEBVIEW.viewUrls.length <= 2,
});
WEBVIEW.navigation.webContents.send("button-hidden", {
id: "next",
hidden: WEBVIEW.viewUrls.length <= 2,
});
// Disable pager buttons
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "previous",
disabled: WEBVIEW.viewActive <= 1,
});
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "next",
disabled: WEBVIEW.viewActive >= WEBVIEW.viewUrls.length - 1,
});
// Update url text
WEBVIEW.navigation.webContents.send("input-text", {
id: "url",
text: currentUrl.startsWith("data:") ? "" : currentUrl,
placeholder: defaultUrl.startsWith("data:") ? "" : defaultUrl,
});
// Disable zoom buttons
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "minus",
disabled: view.webContents.getZoomFactor().toFixed(2) <= 0.25,
});
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "plus",
disabled: view.webContents.getZoomFactor().toFixed(2) >= 4.0,
});
// Disable history buttons
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "backward",
disabled: !view.webContents.navigationHistory.canGoBack(),
});
WEBVIEW.navigation.webContents.send("button-disabled", {
id: "forward",
disabled: !view.webContents.navigationHistory.canGoForward(),
});
};
/**
* Shows or hides the webview navigation bar.
*/
const toggleNavigation = () => {
const window = WEBVIEW.window.getBounds();
const navigation = WEBVIEW.navigation.getBounds();
const height = navigation.height > 0 ? 0 : 60;
// Show or hide navigation based on height
WEBVIEW.navigation.setBounds({
x: 0,
y: window.height - height,
width: window.width,
height: height,
});
if (height > 0) {
WEBVIEW.navigation.webContents.focus();
} else {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
}
// Resize webview
resizeView();
};
/**
* Decreases page zoom on the active webview.
*/
const zoomMinus = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
view.webContents.setZoomFactor(Math.max(0.25, view.webContents.getZoomFactor() - 0.1));
update();
};
/**
* Increases page zoom on the active webview.
*/
const zoomPlus = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
view.webContents.setZoomFactor(Math.min(4.0, view.webContents.getZoomFactor() + 0.1));
update();
};
/**
* Navigates backward in the history of the active webview.
*/
const historyBackward = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
if (view.webContents.navigationHistory.canGoBack()) {
view.webContents.navigationHistory.goBack();
}
};
/**
* Navigates forward in the history of the active webview.
*/
const historyForward = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
if (view.webContents.navigationHistory.canGoForward()) {
view.webContents.navigationHistory.goForward();
}
};
/**
* Activates the previous webview page.
*/
const previousView = () => {
if (WEBVIEW.viewActive > 1) WEBVIEW.viewActive--;
updateView();
};
/**
* Activates the next webview page.
*/
const nextView = () => {
if (WEBVIEW.viewActive < WEBVIEW.views.length - 1) WEBVIEW.viewActive++;
updateView();
};
/**
* Reloads the default url and settings on the active webview.
*/
const homeView = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
// Reload the default url or refresh the page
if (currentUrl != defaultUrl) {
view.webContents.loadURL(defaultUrl);
} else {
view.webContents.reloadIgnoringCache();
}
// Reset page zoom and history
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
setTimeout(() => {
view.webContents.navigationHistory.clear();
update();
}, 2000);
};
/**
* Reloads the current url on the active webview.
*/
const reloadView = () => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = view.webContents.getURL();
// Reload the default url or refresh the page
if (currentUrl.startsWith("data:")) {
view.webContents.loadURL(defaultUrl);
} else {
view.webContents.reloadIgnoringCache();
}
update();
};
/**
* Resizes and positions all webviews.
*/
const resizeView = () => {
const window = WEBVIEW.window.getBounds();
const navigation = WEBVIEW.navigation.getBounds();
const pager = { width: 20, height: window.height };
const widget = { width: 60, height: 200 };
// Update view size
WEBVIEW.views.forEach((view) => {
view.setBounds({
x: 0,
y: 0,
width: window.width,
height: window.height - navigation.height,
});
});
// Update pager size
if (WEBVIEW.pagerEnabled) {
WEBVIEW.pager.setBounds({
x: window.width - pager.width,
y: 0,
width: pager.width,
height: pager.height,
});
WEBVIEW.pager.webContents.send("data-theme", { theme: "hidden" });
}
// Update widget size
if (WEBVIEW.widgetEnabled) {
WEBVIEW.widget.setBounds({
x: window.width - 20,
y: parseInt(window.height / 2 - widget.height / 2, 10),
width: widget.width,
height: widget.height,
});
WEBVIEW.widget.webContents.send("data-theme", { theme: "hidden" });
}
// Update navigation size
if (WEBVIEW.navigationEnabled) {
WEBVIEW.navigation.setBounds({
x: 0,
y: window.height - navigation.height,
width: window.width,
height: navigation.height,
});
WEBVIEW.navigation.webContents.send("data-theme", { theme: WEBVIEW.navigationTheme });
}
};
/**
* Register window events and handler.
*/
const windowEvents = () => {
// Handle window resize events
WEBVIEW.window.on("ready-to-show", resizeView);
WEBVIEW.window.on("resize", resizeView);
resizeView();
// Handle window focus events
WEBVIEW.window.on("focus", () => {
if (WEBVIEW.locked) {
hardware.setDisplayStatus("ON");
WEBVIEW.window.blur();
}
WEBVIEW.locked = false;
});
HARDWARE.events.on("updateDisplay", () => {
WEBVIEW.locked = hardware.getDisplayStatus() === "OFF";
if (WEBVIEW.locked) {
WEBVIEW.window.blur();
}
});
// Handle window status updates
WEBVIEW.window.on("minimize", update);
WEBVIEW.window.on("restore", update);
WEBVIEW.window.on("maximize", update);
WEBVIEW.window.on("unmaximize", update);
WEBVIEW.window.on("enter-full-screen", update);
WEBVIEW.window.on("leave-full-screen", update);
// Handle global shortcut events
globalShortcut.register("Control+Left", () => {
previousView();
});
globalShortcut.register("Control+Right", () => {
nextView();
});
globalShortcut.register("Control+numsub", () => {
zoomMinus();
});
globalShortcut.register("Control+numadd", () => {
zoomPlus();
});
globalShortcut.register("Alt+Left", () => {
historyBackward();
});
globalShortcut.register("Alt+Right", () => {
historyForward();
});
// Handle window touch events
setInterval(() => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const delta = Math.abs(now - then) / 1000;
// Auto-hide navigation
if (delta > 60) {
const navigation = WEBVIEW.navigation.getBounds();
if (navigation.height > 0) {
toggleNavigation();
}
}
}, 1000);
};
/**
* Register widget events and handler.
*/
const widgetEvents = () => {
if (!WEBVIEW.widgetEnabled) {
return;
}
// Handle widget focus events
WEBVIEW.widget.webContents.on("focus", () => {
const window = WEBVIEW.window.getBounds();
const widget = WEBVIEW.widget.getBounds();
// Show widget
WEBVIEW.widget.setBounds({
x: window.width - 60,
y: widget.y,
width: widget.width,
height: widget.height,
});
WEBVIEW.widget.webContents.send("data-theme", { theme: WEBVIEW.widgetTheme });
});
// Handle widget blur events
WEBVIEW.widget.webContents.on("blur", () => {
const window = WEBVIEW.window.getBounds();
const widget = WEBVIEW.widget.getBounds();
// Hide widget
WEBVIEW.widget.setBounds({
x: window.width - 20,
y: widget.y,
width: widget.width,
height: widget.height,
});
WEBVIEW.widget.webContents.send("data-theme", { theme: "hidden" });
});
// Handle widget button click events
ipcMain.on("button-click", (e, button) => {
switch (button.id) {
case "keyboard":
const toggle = hardware.getKeyboardVisibility() === "ON" ? "OFF" : "ON";
switch (toggle) {
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;
}
hardware.setKeyboardVisibility(toggle, () => {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
});
break;
case "fullscreen":
if (WEBVIEW.window.isFullScreen()) {
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(false);
} else {
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
}
hardware.setKeyboardVisibility("OFF", () => {
WEBVIEW.views[WEBVIEW.viewActive].webContents.focus();
});
break;
case "minimize":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.minimize();
hardware.setKeyboardVisibility("OFF");
break;
case "navigation":
toggleNavigation();
break;
}
});
};
/**
* Register navigation events and handler.
*/
const navigationEvents = () => {
if (!WEBVIEW.navigationEnabled) {
return;
}
// Handle navigation blur events
let ignoreBlur = false;
WEBVIEW.navigation.webContents.on("blur", () => {
const visibility = hardware.getKeyboardVisibility();
if (visibility === "ON" && !ignoreBlur) {
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
hardware.setKeyboardVisibility("OFF");
WEBVIEW.navigation.webContents.send("input-blur", { id: "url" });
}
});
// Handle input focus events
ipcMain.on("input-focus", (e, input) => {
const visibility = hardware.getKeyboardVisibility();
switch (input.id) {
case "url":
if (visibility === "OFF") {
ignoreBlur = true;
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.maximize();
hardware.setKeyboardVisibility("ON", () => {
setTimeout(() => {
WEBVIEW.navigation.webContents.focus();
WEBVIEW.navigation.webContents.send("input-select", { id: "url" });
ignoreBlur = false;
}, 500);
});
}
break;
}
});
// Handle input enter events
ipcMain.on("input-enter", (e, input) => {
switch (input.id) {
case "url":
let url = input.text.trim();
if (url && !/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) {
url = "https://" + url;
}
if (!url) {
url = WEBVIEW.viewUrls[WEBVIEW.viewActive];
}
WEBVIEW.views[WEBVIEW.viewActive].webContents.loadURL(url);
break;
}
});
// Handle navigation button click events
ipcMain.on("button-click", (e, button) => {
const view = WEBVIEW.views[WEBVIEW.viewActive];
switch (button.id) {
case "home":
homeView();
hardware.setKeyboardVisibility("OFF");
break;
case "refresh":
reloadView();
hardware.setKeyboardVisibility("OFF");
break;
case "previous":
previousView();
hardware.setKeyboardVisibility("OFF");
break;
case "next":
nextView();
hardware.setKeyboardVisibility("OFF");
break;
case "minus":
zoomMinus();
hardware.setKeyboardVisibility("OFF");
break;
case "plus":
zoomPlus();
hardware.setKeyboardVisibility("OFF");
break;
case "backward":
historyBackward();
hardware.setKeyboardVisibility("OFF");
break;
case "forward":
historyForward();
hardware.setKeyboardVisibility("OFF");
break;
}
});
};
/**
* Register view events and handler.
*/
const viewEvents = () => {
const ready = [];
WEBVIEW.views.forEach((view, i) => {
// Enable webview touch emulation
view.webContents.debugger.attach("1.1");
view.webContents.debugger.sendCommand("Emulation.setEmitTouchEventsForMouse", {
configuration: "mobile",
enabled: true,
});
// Redirect webview hyperlinks
view.webContents.setWindowOpenHandler(({ url }) => {
view.webContents.loadURL(url);
return { action: "deny" };
});
// Update webview layout
view.webContents.on("dom-ready", () => {
view.webContents.insertCSS("::-webkit-scrollbar { display: none; }");
if (ready.length < WEBVIEW.views.length) {
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
ready.push(i);
}
});
// Webview fully loaded
view.webContents.on("did-finish-load", () => {
if (WEBVIEW.viewActive === 0 && ready.length === WEBVIEW.views.length) {
nextView();
}
if (ARGS.app_debug === "true") {
setTimeout(() => {
view.webContents.openDevTools();
}, 2000);
}
});
// Webview not loaded
view.webContents.on("did-fail-load", (e, code, text, url, mainframe) => {
if (mainframe) {
if (WEBVIEW.viewActive === 0 && ready.length === WEBVIEW.views.length) {
nextView();
}
view.webContents.loadURL(errorHtml(code, text, url));
}
});
// Webview url changed
view.webContents.on("did-navigate-in-page", (e, url, mainframe) => {
if (mainframe) {
updateView();
}
});
view.webContents.on("did-navigate", () => {
updateView();
});
// Handle webview mouse events
view.webContents.on("before-mouse-event", (e, mouse) => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const delta = Math.abs(now - then) / 1000;
WEBVIEW.pointer.time = now;
switch (mouse.type) {
case "mouseMove":
if (delta > 30) {
console.log("Update Last Active");
integration.update();
}
break;
case "mouseDown":
switch (mouse.button) {
case "back":
historyBackward();
break;
case "forward":
historyForward();
break;
}
break;
}
});
});
};
/**
* Register app events and handler.
*/
const appEvents = () => {
// Handle signal and exit events
process.on("SIGINT", app.quit);
app.on("before-quit", () => {
WEBVIEW.status = "Terminated";
integration.update();
});
// Handle multiple instances
app.on("second-instance", () => {
if (WEBVIEW.window.isMinimized()) {
WEBVIEW.window.restore();
}
WEBVIEW.window.focus();
});
// Webview initialized
WEBVIEW.initialized = true;
};
/**
* Checks for network connectivity by requesting a known url.
*
* @param {string} url - Url to request.
* @param {number} interval - Interval between requests in milliseconds.
* @param {number} timeout - Maximum time to repeat requests in milliseconds.
* @returns {Promise<boolean>} Resolves true if online, false on timeout.
*/
const onlineStatus = (url, interval = 1000, timeout = 60000) => {
return new Promise((resolve) => {
const start = Date.now();
const check = async () => {
const elapsed = Date.now() - start;
try {
if (!url.startsWith("data:")) {
await axios.get(url, { timeout: 10000 });
}
resolve(true);
} catch (error) {
if (elapsed >= interval) {
console.warn(`Checking Connection: ${url}`, error.message);
}
if (elapsed >= timeout) {
resolve(false);
} else {
setTimeout(check, interval);
}
}
};
check();
});
};
/**
* Generates a html template for a spinning loader.
*
* @param {number} size - The size of the circle.
* @param {number} speed - The rotation speed of the circle.
* @param {string} theme - The theme used for spinner colors.
* @returns {string} A data string with the generated html.
*/
const loaderHtml = (size, speed, theme) => {
const color = {
dark: { border: "#2A2A2A", spinner: "#03A9F4", background: "#111111" },
light: { border: "#DCDCDC", spinner: "#03A9F4", background: "#FAFAFA" },
}[theme];
const html = `
<html>
<head>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
background-color: ${color.background};
}
.spinner {
width: ${size}px;
height: ${size}px;
border-radius: 50%;
border: 4px solid ${color.border};
border-top-color: ${color.spinner};
animation: spin ${speed}s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="spinner"></div>
</body>
</html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
};
/**
* Generates a html template for an error page.
*
* @param {number} code - The error code of the response.
* @param {string} text - The error text of the response.
* @param {string} url - The url of the requested page.
* @returns {string} A data string with the generated html.
*/
const errorHtml = (code, text, url) => {
const html = `
<html>
<head>
<style>
body {
display: flex;
align-items: center;
justify-content: center;
font-family: sans-serif;
text-align: center;
}
.icon {
margin: 0;
font-size: 5rem;
color: orange;
}
.title {
margin: 0;
}
</style>
</head>
<body>
<div>
<p class="icon">&#9888;</p>
<h1 class="title">Whoopsie!</h1>
<p><strong>Loading:</strong> ${url}</p>
<p><strong>Error:</strong> ${text} (${code})</p>
</div>
</body>
</html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
};
module.exports = {
init,
update,
};
+13 -47
View File
@@ -1,62 +1,28 @@
{ {
"name": "touchkio", "name": "touchkio",
"description": "Home Assistant Touch Kiosk", "description": "Home Assistant Touch Kiosk",
"homepage": "https://github.com/leukipp/touchkio",
"author": "leukipp", "author": "leukipp",
"version": "1.0.0", "version": "1.2.1",
"license": "MIT", "license": "MIT",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "electron . --web-url=https://demo.home-assistant.io", "start": "electron . --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app --app-debug=false --trace-warnings",
"build": "electron-forge make" "build": "electron-forge make"
}, },
"config": { "prettier": {
"forge": { "printWidth": 120
"packagerConfig": {
"ignore": [
".gitignore",
".github/",
"docs/"
]
},
"makers": [
{
"name": "@electron-forge/maker-zip"
},
{
"name": "@electron-forge/maker-deb",
"config": {
"options": {
"productName": "TouchKio",
"icon": "img/icon.png",
"categories": [
"Network"
]
}
}
}
],
"publishers": [
{
"name": "@electron-forge/publisher-github",
"config": {
"repository": {
"owner": "leukipp",
"name": "touchkio"
},
"draft": true
}
}
]
}
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.6.0", "@electron-forge/cli": "^7.8.3",
"@electron-forge/maker-deb": "^7.6.0", "@electron-forge/maker-deb": "^7.8.3",
"@electron-forge/maker-zip": "^7.6.0", "@electron-forge/maker-zip": "^7.8.3",
"@electron-forge/publisher-github": "^7.6.0", "@electron-forge/publisher-github": "^7.8.3",
"electron": "^33.2.1" "electron": "^37.4.0",
"prettier": "^3.6.2"
}, },
"dependencies": { "dependencies": {
"mqtt": "^5.10.3" "axios": "^1.11.0",
"mqtt": "^5.14.0"
} }
} }
-389
View File
@@ -1,389 +0,0 @@
const os = require("os");
const fs = require("fs");
const process = require("child_process");
global.HARDWARE = global.HARDWARE || {
initialized: false,
status: "invalid",
display: {
status: null,
brightness: null,
notifiers: [],
},
};
/**
* Initializes the hardware with the provided arguments.
*
* @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful.
*/
const init = async (args) => {
if (!args.mqtt_url) {
return false;
}
// Check supported hardware
if (!hardwareExists()) {
console.warn("Hardware not supported");
return false;
}
// Check supported model
const model = getModel();
if (!model || !model.includes("Raspberry Pi 5")) {
console.warn(`Device ${model} not supported`);
return false;
}
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);
return true;
};
/**
* Updates the shared hardware properties.
*/
const update = () => {
if (!HARDWARE.initialized) {
return;
}
const path = "/sys/class/backlight/10-0045";
// Display status has changed
const status = fs.readFileSync(`${path}/bl_power`, "utf8").trim();
if (status !== HARDWARE.display.status) {
console.log("Update Display Status:", getDisplayStatus());
HARDWARE.display.status = status;
HARDWARE.display.notifiers.forEach((notifier) => {
notifier();
});
}
// Display brightness has changed
const brightness = fs.readFileSync(`${path}/brightness`, "utf8").trim();
if (brightness !== HARDWARE.display.brightness) {
console.log("Update Display Brightness:", getDisplayBrightness());
HARDWARE.display.brightness = brightness;
HARDWARE.display.notifiers.forEach((notifier) => {
notifier();
});
}
};
/**
* Verifies hardware compatibility by checking the presence of necessary sys files.
*
* @returns {bool} Returns true if all files exists.
*/
const hardwareExists = () => {
const files = [
"/sys/firmware/devicetree/base/model",
"/sys/firmware/devicetree/base/serial-number",
"/sys/class/backlight/10-0045/brightness",
"/sys/class/backlight/10-0045/bl_power",
];
return files.every((file) => fs.existsSync(file));
};
/**
* Gets the Raspberry Pi model name using `/sys/firmware/devicetree/base/model`.
*
* @returns {string|null} The model of the Raspberry Pi or null if an error occurs.
*/
const getModel = () => {
return execSyncCommand("cat", ["/sys/firmware/devicetree/base/model"]);
};
/**
* Gets the Raspberry Pi serial number using `/sys/firmware/devicetree/base/serial-number`.
*
* @returns {string|null} The serial number of the Raspberry Pi or null if an error occurs.
*/
const getSerialNumber = () => {
return execSyncCommand("cat", [
"/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"]);
};
/**
* Gets the host name of the current system using `os.hostname()`.
*
* @returns {string} The host name of the system.
*/
const getHostName = () => {
return os.hostname();
};
/**
* Gets the up time of the system in minutes using `os.uptime()`.
*
* @returns {number} The up time of the system in minutes.
*/
const getUpTime = () => {
return os.uptime() / 60;
};
/**
* Gets the total available memory in gibibytes using `os.totalmem()`.
*
* @returns {number} The total available memory in GiB.
*/
const getMemorySize = () => {
return os.totalmem() / 1024 ** 3;
};
/**
* Gets the current memory usage as a percentage using `os.totalmem()` and `os.freemem()`.
*
* @returns {number} The percentage of used memory.
*/
const getMemoryUsage = () => {
return ((os.totalmem() - os.freemem()) / os.totalmem()) * 100;
};
/**
* Gets the CPU load average over the last 5 minutes as a percentage using `os.loadavg()` and `os.cpus()`.
*
* @returns {number} The CPU load average percentage over the last 5 minutes.
*/
const getProcessorUsage = () => {
return (os.loadavg()[1] / os.cpus().length) * 100;
};
/**
* Gets the current CPU temperature using `vcgencmd measure_temp`.
*
* @returns {number|null} The CPU temperature in degrees Celsius or null if an error occurs.
*/
const getProcessorTemperature = () => {
const output = execSyncCommand("vcgencmd", ["measure_temp"]);
if (output !== null) {
const temp = output.match(/temp=(\d+\.\d+)/);
if (temp && temp[1]) {
return parseFloat(temp[1]);
}
}
return null;
};
/**
* Gets the current display power status using `wlopm`.
*
* @returns {string|null} The output of the command or null if an error occurs.
*/
const getDisplayStatus = () => {
const status = execSyncCommand("wlopm", []);
if (status !== null) {
return status.slice(status.lastIndexOf(" ") + 1).toUpperCase();
}
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";
}
*/
};
/**
* Sets the display power status using `wlopm`.
*
* This function takes a desired status ('ON' or 'OFF') and executes
* the appropriate command to set the display status.
*
* @param {string} status - The desired status ('ON' or 'OFF').
* @param {function} callback - A callback function that receives the output or error.
*/
const setDisplayStatus = (status, callback = null) => {
if (status !== "ON" && status !== "OFF") {
console.error("Status must be 'ON' or 'OFF'");
if (typeof callback === "function") callback(null, "Invalid status");
return;
}
const args = status === "ON" ? ["--on", "*"] : ["--off", "*"];
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`.
*
* This function retrieves the brightness level, which is in the range of 0-31,
* and maps it to a percentage in the range of 0-100 percent.
*
* @returns {number|null} The brightness level as a percentage or null if an error occurs.
*/
const getDisplayBrightness = () => {
const brightness = execSyncCommand("cat", [
"/sys/class/backlight/10-0045/brightness",
]);
if (brightness !== null) {
return Math.round((parseInt(brightness, 10) / 31) * 100);
}
return null;
};
/**
* Sets the display brightness level using `/sys/class/backlight/10-0045/brightness`.
*
* This function takes a brightness value in the range of 1-100 percent,
* maps it to the range of 0-31, and writes it to the system.
*
* @param {number} brightness - The desired brightness level (1-100).
* @param {function} callback - A callback function that receives the output or error.
*/
const setDisplayBrightness = (brightness, callback = null) => {
if (typeof brightness !== "number" || brightness < 1 || brightness > 100) {
console.error("Brightness must be a number between 1 and 100");
if (typeof callback === "function") callback(null, "Invalid brightness");
return;
}
const value = Math.max(1, Math.min(Math.round((brightness / 100) * 31), 31));
const proc = execAsyncCommand(
"tee",
["/sys/class/backlight/10-0045/brightness"],
callback
);
proc.stdin.write(value.toString());
proc.stdin.end();
};
/**
* Shuts down the system using `sudo shutdown -h now`.
*
* This function executes the command asynchronously.
* The output of the command will be provided through the callback function.
*
* @param {function} callback - A callback function that receives the output or error.
*/
const shutdownSystem = (callback = null) => {
execAsyncCommand("sudo", ["shutdown", "-h", "now"], callback);
};
/**
* Reboots the system using `sudo reboot`.
*
* This function executes the command asynchronously.
* The output of the command will be provided through the callback function.
*
* @param {function} callback - A callback function that receives the output or error.
*/
const rebootSystem = (callback = null) => {
execAsyncCommand("sudo", ["reboot"], callback);
};
/**
* Executes a command synchronously and returns the output.
*
* @param {string} cmd - The command to execute.
* @param {Array<string>} args - The arguments for the command.
* @returns {string|null} The output of the command or null if an error occurs.
*/
const execSyncCommand = (cmd, args = []) => {
try {
const output = process.execSync([cmd, ...args].join(" "), {
encoding: "utf8",
});
return output.trim().replace(/\0/g, "");
} catch (error) {
console.error("Execute Sync:", error.message);
}
return null;
};
/**
* Executes a command asynchronously.
*
* @param {string} cmd - The command to execute.
* @param {Array<string>} args - The arguments for the command.
* @param {function} callback - A callback function that receives the output or error.
* @returns {object} The spawned process object.
*/
const execAsyncCommand = (cmd, args = [], callback = null) => {
let errorOutput = "";
let successOutput = "";
let proc = process.spawn(cmd, args);
proc.stderr.on("data", (data) => {
if (data) {
errorOutput += data.toString();
}
});
proc.stdout.on("data", (data) => {
if (data) {
successOutput += data.toString();
}
});
proc.on("close", (code) => {
try {
if (typeof callback === "function") {
if (code === 0) {
callback(successOutput.trim().replace(/\0/g, ""), null);
} else {
callback(null, errorOutput.trim().replace(/\0/g, ""));
}
}
} catch (error) {
console.error("Execute Async:", error.message);
}
});
return proc;
};
module.exports = {
init,
update,
getModel,
getSerialNumber,
getMachineId,
getHostName,
getUpTime,
getMemorySize,
getMemoryUsage,
getProcessorUsage,
getProcessorTemperature,
getDisplayStatus,
setDisplayStatus,
getDisplayBrightness,
setDisplayBrightness,
shutdownSystem,
rebootSystem,
};
-677
View File
@@ -1,677 +0,0 @@
const mqtt = require("mqtt");
const hardware = require("./hardware");
const { app } = require("electron");
global.INTEGRATION = global.INTEGRATION || {
initialized: false,
status: "offline",
};
/**
* Initializes the integration with the provided arguments.
*
* @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful.
*/
const init = async (args) => {
if (!args.mqtt_url) {
return false;
}
const url = new URL(args.mqtt_url);
const user = args.mqtt_user ? args.mqtt_user : null;
const password = args.mqtt_password ? args.mqtt_password : null;
const discovery = args.mqtt_discovery ? args.mqtt_discovery : "homeassistant";
const model = hardware.getModel();
const hostName = hardware.getHostName();
const serialNumber = hardware.getSerialNumber();
const deviceId = serialNumber.slice(-6).toUpperCase();
const deviceName = hostName.charAt(0).toUpperCase() + hostName.slice(1);
// Init globals
INTEGRATION.node = `rpi_${deviceId}`;
INTEGRATION.discovery = discovery;
INTEGRATION.device = {
name: `Raspberry Pi ${deviceName}`,
model: model,
serial_number: serialNumber,
manufacturer: "Raspberry Pi Ltd",
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,
};
console.log("MQTT Connecting:", `${user}:${masked}@${url.toString()}`);
INTEGRATION.client = mqtt.connect(url.toString(), options);
// Client connected
INTEGRATION.client
.on("connect", () => {
console.log(`MQTT Connected: ${url.toString()}\n`);
// Init client controls
initShutdown(INTEGRATION.client);
initReboot(INTEGRATION.client);
initRefresh(INTEGRATION.client);
initKiosk(INTEGRATION.client);
initDisplay(INTEGRATION.client);
// Init client sensors
initModel(INTEGRATION.client);
initSerialNumber(INTEGRATION.client);
initHostName(INTEGRATION.client);
initUpTime(INTEGRATION.client);
initMemorySize(INTEGRATION.client);
initMemoryUsage(INTEGRATION.client);
initProcessorUsage(INTEGRATION.client);
initProcessorTemperature(INTEGRATION.client);
initHeartbeat(INTEGRATION.client);
// Integration initialized
INTEGRATION.initialized = true;
INTEGRATION.status = "online";
})
.on("error", (error) => {
console.error("MQTT:", error);
INTEGRATION.status = "offline";
});
// Update sensor states on display change
HARDWARE.display.notifiers.push(() => {
updateDisplay(INTEGRATION.client);
});
// Update heartbeat time periodically
setInterval(() => {
updateHeartbeat(INTEGRATION.client);
}, 30 * 1000);
// Update sensor states periodically
setInterval(update, 60 * 1000);
return true;
};
/**
* Updates the shared integration properties.
*/
const update = () => {
if (!INTEGRATION.initialized) {
return;
}
// Update client sensors
updateKiosk(INTEGRATION.client);
updateUpTime(INTEGRATION.client);
updateMemoryUsage(INTEGRATION.client);
updateProcessorUsage(INTEGRATION.client);
updateProcessorTemperature(INTEGRATION.client);
};
/**
* Initializes the shutdown button and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initShutdown = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/shutdown`;
client
.publish(
`${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) => {
if (topic === `${root}/execute`) {
console.log("Shutdown system...");
hardware.setDisplayStatus("ON");
hardware.shutdownSystem();
}
})
.subscribe(`${root}/execute`);
};
/**
* Initializes the reboot button and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initReboot = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/reboot`;
client
.publish(
`${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) => {
if (topic === `${root}/execute`) {
console.log("Rebooting system...");
hardware.setDisplayStatus("ON");
hardware.rebootSystem();
}
})
.subscribe(`${root}/execute`);
};
/**
* Initializes the refresh button and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initRefresh = (client) => {
const root = `${INTEGRATION.discovery}/button/${INTEGRATION.node}/refresh`;
client
.publish(
`${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) => {
if (topic === `${root}/execute`) {
console.log("Refreshing webview...");
hardware.setDisplayStatus("ON");
WEBVIEW.view.webContents.reloadIgnoringCache();
}
})
.subscribe(`${root}/execute`);
};
/**
* Initializes the kiosk select status and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initKiosk = (client) => {
const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`;
client
.publish(
`${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) => {
if (topic === `${root}/set`) {
const status = message.toString();
console.log("Set Kiosk Status:", status);
hardware.setDisplayStatus("ON");
switch (status) {
case "Framed":
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(false);
break;
case "Fullscreen":
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
break;
case "Maximized":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.maximize();
break;
case "Minimized":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.minimize();
break;
case "Terminated":
app.quit();
}
}
})
.subscribe(`${root}/set`);
updateKiosk(client);
};
/**
* Updates the kiosk status via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateKiosk = (client) => {
const kiosk = WEBVIEW.status;
const root = `${INTEGRATION.discovery}/select/${INTEGRATION.node}/kiosk`;
client.publish(`${root}/status`, `${kiosk}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the display status, brightness and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initDisplay = (client) => {
const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`;
client
.publish(
`${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) => {
if (topic === `${root}/set`) {
const status = message.toString();
console.log("Set Display Status:", status);
hardware.setDisplayStatus(status);
} else if (topic === `${root}/brightness/set`) {
const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness);
}
})
.subscribe(`${root}/set`)
.subscribe(`${root}/brightness/set`);
updateDisplay(client);
};
/**
* Updates the display status, brightness via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateDisplay = (client) => {
const status = hardware.getDisplayStatus();
const brightness = hardware.getDisplayBrightness();
const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`;
client.publish(`${root}/status`, `${status}`, {
qos: 1,
retain: true,
});
client.publish(`${root}/brightness/status`, `${brightness}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the model sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initModel = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Model`,
unique_id: `${INTEGRATION.node}_model`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:raspberry-pi",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateModel(client);
};
/**
* Updates the model sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateModel = (client) => {
const model = hardware.getModel();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`;
client.publish(`${root}/status`, `${model}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the serial number sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initSerialNumber = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Serial Number`,
unique_id: `${INTEGRATION.node}_serial_number`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:hexadecimal",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateSerialNumber(client);
};
/**
* Updates the serial number sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateSerialNumber = (client) => {
const serialNumber = hardware.getSerialNumber();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/serial_number`;
client.publish(`${root}/status`, `${serialNumber}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the host name sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initHostName = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Host Name`,
unique_id: `${INTEGRATION.node}_host_name`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:console-network",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateHostName(client);
};
/**
* Updates the host name sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateHostName = (client) => {
const hostName = hardware.getHostName();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/host_name`;
client.publish(`${root}/status`, `${hostName}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the up time sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initUpTime = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Up Time`,
unique_id: `${INTEGRATION.node}_up_time`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min",
icon: "mdi:timeline-clock",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateUpTime(client);
};
/**
* Updates the up time sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateUpTime = (client) => {
const upTime = hardware.getUpTime();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/up_time`;
client.publish(`${root}/status`, `${upTime}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the memory size sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initMemorySize = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Memory Size`,
unique_id: `${INTEGRATION.node}_memory_size`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(2) }}",
unit_of_measurement: "GiB",
icon: "mdi:memory",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateMemorySize(client);
};
/**
* Updates the memory size sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateMemorySize = (client) => {
const memorySize = hardware.getMemorySize();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_size`;
client.publish(`${root}/status`, `${memorySize}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the memory usage sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initMemoryUsage = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Memory Usage`,
unique_id: `${INTEGRATION.node}_memory_usage`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "%",
icon: "mdi:memory-arrow-down",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateMemoryUsage(client);
};
/**
* Updates the memory usage sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateMemoryUsage = (client) => {
const memoryUsage = hardware.getMemoryUsage();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/memory_usage`;
client.publish(`${root}/status`, `${memoryUsage}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the processor usage sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initProcessorUsage = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Processor Usage`,
unique_id: `${INTEGRATION.node}_processor_usage`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "%",
icon: "mdi:cpu-64-bit",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateProcessorUsage(client);
};
/**
* Updates the processor usage sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateProcessorUsage = (client) => {
const processorUsage = hardware.getProcessorUsage();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_usage`;
client.publish(`${root}/status`, `${processorUsage}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the processor temperature sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initProcessorTemperature = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Processor Temperature`,
unique_id: `${INTEGRATION.node}_processor_temperature`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "°C",
icon: "mdi:radiator",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateProcessorTemperature(client);
};
/**
* Updates the processor temperature sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateProcessorTemperature = (client) => {
const processorTemperature = hardware.getProcessorTemperature();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/processor_temperature`;
client.publish(`${root}/status`, `${processorTemperature}`, {
qos: 1,
retain: true,
});
};
/**
* Initializes the heartbeat sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initHeartbeat = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`;
client.publish(
`${root}/config`,
JSON.stringify({
name: `Heartbeat`,
unique_id: `${INTEGRATION.node}_heartbeat`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:heart-flash",
device: INTEGRATION.device,
}),
{ qos: 1, retain: true }
);
updateHeartbeat(client);
};
/**
* Updates the heartbeat sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateHeartbeat = (client) => {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, "");
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`;
client.publish(`${root}/status`, `${heartbeat}`, {
qos: 1,
retain: true,
});
};
module.exports = {
init,
update,
};
-167
View File
@@ -1,167 +0,0 @@
const path = require("path");
const hardware = require("./hardware");
const integration = require("./integration");
const { app, nativeTheme, BaseWindow, WebContentsView } = require("electron");
global.WEBVIEW = global.WEBVIEW || {
initialized: false,
status: "offline",
locked: false,
};
/**
* Initializes the webview with the provided arguments.
*
* @param {Object} args - The command-line arguments to customize the initialization process.
* @returns {boolean} Returns true if the initialization was successful.
*/
const init = async (args) => {
if (!args.web_url) {
console.error("Please provide the '--web-url' parameter");
return app.quit();
}
const url = new URL(args.web_url);
const theme = args.web_theme ? args.web_theme : "dark";
const zoom = args.web_zoom ? parseFloat(args.web_zoom) : 1.25;
// Init global root window
nativeTheme.themeSource = theme;
WEBVIEW.window = new BaseWindow({
title: `TouchKio - ${url.host}`,
icon: path.join(__dirname, "..", "img", "icon.png"),
});
WEBVIEW.window.setMenuBarVisibility(false);
WEBVIEW.window.setFullScreen(true);
// Init global main webview
WEBVIEW.view = new WebContentsView();
WEBVIEW.window.contentView.addChildView(WEBVIEW.view);
WEBVIEW.view.webContents.loadURL(url.toString());
// Register events
windowEvents(WEBVIEW.window, zoom);
viewEvents(WEBVIEW.view);
return true;
};
/**
* Updates the shared webview properties.
*/
const update = () => {
if (!WEBVIEW.initialized) {
return;
}
// Update window status
if (WEBVIEW.window.isFullScreen()) {
WEBVIEW.status = "Fullscreen";
} else if (WEBVIEW.window.isMinimized()) {
WEBVIEW.status = "Minimized";
} else if (WEBVIEW.window.isMaximized()) {
WEBVIEW.status = "Maximized";
} else {
WEBVIEW.status = "Framed";
}
// Update integration sensor
console.log("Update Kiosk Status:", WEBVIEW.status);
integration.update();
};
/**
* Register window events and handler.
*
* @param {Object} window - The root window object.
* @param {number} zoom - Zoom level used for the webview.
*/
const windowEvents = (window, zoom) => {
// Handle window resize for webview bounds
const resize = () => {
const { width, height } = window.getBounds();
WEBVIEW.view.setBounds({ x: 0, y: 0, width: width, height: height });
WEBVIEW.view.webContents.setZoomFactor(zoom);
};
window.on("ready-to-show", resize);
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(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);
window.on("maximize", update);
window.on("unmaximize", update);
window.on("enter-full-screen", update);
window.on("leave-full-screen", update);
// Handle signal and exit events
process.on("SIGINT", app.quit);
app.on("before-quit", () => {
WEBVIEW.status = "Terminated";
integration.update();
});
// Handle multiple instances
app.on("second-instance", () => {
if (window.isMinimized()) {
window.restore();
}
window.focus();
});
};
/**
* Register webview events and handler.
*
* @param {Object} view - The main webview object.
*/
const viewEvents = (view) => {
// Enable webview touch emulation
view.webContents.debugger.attach("1.1");
view.webContents.debugger.sendCommand(
"Emulation.setEmitTouchEventsForMouse",
{
enabled: true,
configuration: "mobile",
}
);
// Disable webview hyperlinks
view.webContents.setWindowOpenHandler(() => {
return { action: "deny" };
});
// Remove webview scrollbar
view.webContents.on("dom-ready", () => {
view.webContents.insertCSS("::-webkit-scrollbar { display: none; }");
});
// Webview fully loaded
view.webContents.on("did-finish-load", () => {
WEBVIEW.initialized = true;
update();
});
};
module.exports = {
init,
update,
};
+561 -567
View File
File diff suppressed because it is too large Load Diff