17 Commits
Author SHA1 Message Date
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
15 changed files with 2196 additions and 1621 deletions
+5 -3
View File
@@ -35,10 +35,12 @@ If you are running Linux with a graphical user interface (Wayland or X11), you s
| 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 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 | [Portable Touch 18.5" FHD (1920x1080)](https://www.amazon.de/dp/B0CT2KLDBQ) | 🟨 |
| 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 | Generic Non-Touch | 🟨 |
| Generic PC (x64) | Ubuntu (64-bit), X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu (64-bit), Wayland | Generic Non-Touch | 🟥 |
| Generic PC (x64) | Debian KDE (64-bit), 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.
+38 -23
View File
@@ -11,17 +11,19 @@ Moreover, the device running the **kiosk application** also offers several **Hom
## Features
- [x] Fast and easy setup.
- [x] Remember login credentials.
- [x] Touch optimized web browsing.
- [x] Side panel widget for kiosk control.
- [x] Touch friendly web browser interface.
- [x] Configurable zoom and theme support.
- [x] Single-tap screen wake-up functionality.
- [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] Manage kiosk window status.
- [x] Manage touch keyboard status.
- [x] Toggle the on-screen keyboard.
- [x] Touch display power and brightness.
- [x] Show available system package upgrades.
- [x] Execute system reboot and shutdown commands.
- [x] Multi-webpage switching and url navigation.
- [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.
Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Linux device and the connected Touch Display.
@@ -54,7 +56,7 @@ bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install
```
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.
The script can also be re-run to **update** an existing installation to the latest version.
This command can also be re-run to **update** an existing installation to the latest version.
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:
@@ -69,7 +71,7 @@ When connected via SSH, it's necessary to export the display variables first, as
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 version file that is suitable for your architecture (arm64 or x64).
- Debian (**deb**): Open a terminal and execute the following command to install the application, e.g:
`sudo dpkg -i touchkio_1.x.x_arm64.deb && touchkio --setup`
`sudo apt install ./touchkio_1.x.x_arm64.deb && touchkio --setup`
- 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`
- The `--web-url` doesn't necessarily need to be a Home Assistant url, any kind of website can be shown in kiosk mode.
@@ -93,12 +95,12 @@ These default arguments are stored in `~/.config/touchkio/Arguments.json`, where
### WEB
The available arguments to control the kiosk application via terminal are as follows:
| Name | Default | Description |
| ------------------------- | ------- | ------------------------------------------------------ |
| `--web-url` (Required) | - | Url of the Home Assistant instance (HTTP(S)://IP:PORT) |
| `--web-theme` (Optional) | `dark` | Theme settings of the web browser (`light` or `dark`) |
| `--web-zoom` (Optional) | `1.25` | Zoom settings of the web browser (`1.0` is `100%`) |
| `--web-widget` (Optional) | `true` | Enables the sidebar widget (`true` or `false`) |
| Name | Default | Description |
| ------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
| `--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-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 view.
@@ -107,6 +109,20 @@ For example:
touchkio --web-url=http://192.168.1.42:8123 --web-theme=light --web-zoom=1.0
```
> <a href="#ref1">↩ </a><a id="foot1"></a>**[1]:** Not necessarily a Home Assistant Url.
> You can load multiple pages by separating them with a comma:
> `touchkio --web-url=https://demo.home-assistant.io,https://imvbh.github.io/FlipClock`.
In the `~/.config/touchkio/Arguments.json` file:
```json
{
"web_url": [
"https://demo.home-assistant.io",
"https://imvbh.github.io/FlipClock"
]
}
```
### MQTT
To broadcast your local sensor data to Home Assistant, you can use the following arguments, which require a running MQTT broker:
| Name | Default | Description |
@@ -129,9 +145,9 @@ To create your own local build, you first need to install [Node.js](https://pimy
Clone this repository and run `yarn install` to install the dependencies.
Then use `yarn start` to execute the start script located in the [package.json](https://github.com/leukipp/touchkio/blob/main/package.json) file.
There you can adjust the `--web-url` and other arguments for development runs.
There you may want to adjust the `--web-url` and other arguments for development runs.
If you connect to your device via SSH, you may have to export display variables so that the kiosk application can be loaded inside the desktop environment:
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"
@@ -178,7 +194,7 @@ While using external sensors that directly integrate with Home Assistant and by
<details><summary>Don't waste your time reading this.</summary><div></br>
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.
The kiosk application interacts with squeekboard via this `D-Bus` object path `/sm/puri/OSK0`, enabling the keyboard to be hidden or shown based on **MQTT** user input or system events.
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.
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.
@@ -221,22 +237,21 @@ Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/
- A webview kiosk window launched in fullscreen mode and loading the specified `--web-url` website should not cause any problems.
**Extended features** become available when the `--mqtt-*` arguments are provided and the hardware is supported:
- Sensor features that may work out-of-the-box include those that do not require any interaction with the display or keyboard.
- 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), the display status control may only be available when using 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 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.
- This [issue](https://github.com/leukipp/touchkio/issues/4) also occurs in the default browser but as a current workaround you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the keyboard.
- 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.
- 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.
- 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.
## 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
[MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE)
+174
View File
@@ -0,0 +1,174 @@
<!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.hidden {
display: none;
}
li.disabled {
opacity: 0.5;
pointer-events: none;
}
button {
width: 32px;
height: 32px;
border: none;
background-color: transparent;
}
input {
width: 100%;
min-width: 0;
margin: 0 10px;
padding: 6px 10px;
border-radius: 4px;
font-size: 18px;
outline: none;
border: none;
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="pager">
<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="dynamic">
<input id="url" type="text" spellcheck="false" />
</li>
<li>
<button id="backward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>arrow-left-circle</title>
<path
d="M2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12M18,11H10L13.5,7.5L12.08,6.08L6.16,12L12.08,17.92L13.5,16.5L10,13H18V11Z"
/>
</svg>
</button>
</li>
<li>
<button id="forward">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>arrow-right-circle</title>
<path
d="M22,12A10,10 0 0,1 12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2A10,10 0 0,1 22,12M6,13H14L10.5,16.5L11.92,17.92L17.84,12L11.92,6.08L10.5,7.5L14,11H6V13Z"
/>
</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>
+30 -19
View File
@@ -46,6 +46,14 @@
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;
@@ -61,11 +69,11 @@
<body>
<menu>
<li>
<button id="refresh">
<button id="keyboard">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>web-refresh</title>
<title>keyboard-close-outline</title>
<path
d="M13.82 14H9.66C9.56 13.34 9.5 12.68 9.5 12S9.56 10.65 9.66 10H14.34C14.43 10.65 14.5 11.32 14.5 12C14.5 12.5 14.46 13 14.4 13.46C15 12.96 15.72 12.57 16.5 12.32C16.5 12.21 16.5 12.11 16.5 12C16.5 11.32 16.44 10.66 16.36 10H19.74C19.9 10.64 20 11.31 20 12C20 12.06 20 12.12 20 12.18C20.7 12.35 21.35 12.63 21.95 13C22 12.68 22 12.34 22 12C22 6.5 17.5 2 12 2C6.47 2 2 6.5 2 12C2 17.5 6.5 22 12 22C12.34 22 12.68 22 13 21.95C12.59 21.29 12.29 20.55 12.13 19.75C12.09 19.82 12.05 19.89 12 19.96C11.17 18.76 10.5 17.43 10.09 16H12.5C12.81 15.25 13.26 14.58 13.82 14M18.92 8H15.97C15.65 6.75 15.19 5.55 14.59 4.44C16.43 5.07 17.96 6.34 18.92 8M12 4.03C12.83 5.23 13.5 6.57 13.91 8H10.09C10.5 6.57 11.17 5.23 12 4.03M4.26 14C4.1 13.36 4 12.69 4 12S4.1 10.64 4.26 10H7.64C7.56 10.66 7.5 11.32 7.5 12S7.56 13.34 7.64 14H4.26M5.08 16H8C8.35 17.25 8.8 18.45 9.4 19.56C7.57 18.93 6.03 17.65 5.08 16M8 8H5.08C6.03 6.34 7.57 5.06 9.4 4.44C8.8 5.55 8.35 6.75 8 8M20.83 15.67L22 14.5V18.5H18L19.77 16.73C19.32 16.28 18.69 16 18 16C16.62 16 15.5 17.12 15.5 18.5S16.62 21 18 21C18.82 21 19.54 20.61 20 20H21.71C21.12 21.47 19.68 22.5 18 22.5C15.79 22.5 14 20.71 14 18.5S15.79 14.5 18 14.5C19.11 14.5 20.11 14.95 20.83 15.67Z"
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>
@@ -91,11 +99,11 @@
</button>
</li>
<li>
<button id="keyboard">
<button id="navigation">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>keyboard-close-outline</title>
<title>web</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"
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>
@@ -104,22 +112,25 @@
<script>
const { ipcRenderer } = require("electron");
// Register button click send message
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
});
};
// Register button hide receive message
ipcRenderer.on("button-hide", (e, button) => {
document.getElementById(button.id).parentNode.hidden = true;
});
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);
});
};
// Register data theme receive message
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: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 69 KiB

+28 -13
View File
@@ -2,15 +2,15 @@ const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const readline = require("readline/promises");
const integration = require("./src/integration");
const hardware = require("./src/hardware");
const webview = require("./src/webview");
const integration = require("./js/integration");
const hardware = require("./js/hardware");
const webview = require("./js/webview");
const { app } = require("electron");
global.ARGS = global.ARGS || {};
if (!process.env.DISPLAY) {
console.error(`$DISPLAY variable not set to run the GUI application, are you connected via SSH?`);
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:`);
@@ -34,6 +34,12 @@ app.whenReady().then(async () => {
let argsFilePath = path.join(app.getPath("userData"), "Arguments.json");
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
if ("setup" in args || (!argsProvided && !argsFileExists)) {
await sleep(3000);
@@ -44,6 +50,12 @@ app.whenReady().then(async () => {
} else if (!argsProvided && argsFileExists) {
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
@@ -65,14 +77,13 @@ app.whenReady().then(async () => {
* @returns {Object} An object mapping argument names to their corresponding values.
*/
const parseArgs = (proc) => {
let args = {};
proc.argv.slice(1).forEach((arg) => {
if (arg !== ".") {
const [key, value] = arg.split("=");
args[key.replace("--", "").replace("-", "_")] = value;
}
});
return args;
const args = proc.argv.slice(1).filter((arg) => arg !== ".");
return Object.fromEntries(
args.flatMap((arg) => {
const match = arg.match(/^--?([^=]+)(?:=(.*))?$/);
return match ? [[match[1].replace(/-/g, "_"), match[2] ?? null]] : [];
}),
);
};
/**
@@ -164,7 +175,11 @@ const promptArgs = async (proc) => {
const prompt = `${question} (${fallback}): `;
const answer = await read.question(prompt);
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();
+4 -3
View File
@@ -33,6 +33,7 @@ sed 's/"browser_download_url": "//;s/"//g')
TMP_DIR=$(mktemp -d)
DEB_PATH="${TMP_DIR}/$(basename "$DEB_URL")"
chmod 755 "$TMP_DIR"
if [ -z "$DEB_URL" ]; then
echo "Download url for .deb file not found."
@@ -47,12 +48,12 @@ fi
# Install the latest .deb package
echo -e "\nInstalling the latest release..."
if ! command -v dpkg &> /dev/null; then
echo "Package manager dpkg was not found."
if ! command -v apt &> /dev/null; then
echo "Package manager apt was not found."
exit 1
fi
if ! sudo dpkg -i "$DEB_PATH"; then
if ! sudo apt install "$DEB_PATH"; then
echo "Installation of .deb file failed."
exit 1
fi
+80 -31
View File
@@ -9,6 +9,11 @@ global.HARDWARE = global.HARDWARE || {
status: null,
session: {},
support: {},
battery: {
level: {
path: null,
},
},
display: {
status: {
path: null,
@@ -30,7 +35,7 @@ global.HARDWARE = global.HARDWARE || {
/**
* Initializes the hardware with the provided arguments.
*
* @returns {boolean} Returns true if the initialization was successful.
* @returns {bool} Returns true if the initialization was successful.
*/
const init = async () => {
if (!compatibleDevice()) {
@@ -41,6 +46,8 @@ const init = async () => {
// Init globals
HARDWARE.session.user = sessionUser();
HARDWARE.session.type = sessionType();
HARDWARE.battery.level.path = getBatteryLevelPath();
HARDWARE.support.batteryLevel = HARDWARE.battery.level.path !== null;
HARDWARE.display.status.path = getDisplayStatusPath();
HARDWARE.support.displayStatus = HARDWARE.display.status.path !== null;
HARDWARE.display.brightness.path = getDisplayBrightnessPath();
@@ -70,8 +77,9 @@ const init = async () => {
console.log("Processor Usage:", getProcessorUsage());
console.log("Processor Temperature:", getProcessorTemperature());
// Show display infos
console.log(`\nDisplay Status [${HARDWARE.display.status.path}]:`, getDisplayStatus());
// Show hardware infos
console.log(`\nBattery Level [${HARDWARE.battery.level.path}]:`, getBatteryLevel());
console.log(`Display Status [${HARDWARE.display.status.path}]:`, getDisplayStatus());
console.log(`Display Brightness [${HARDWARE.display.brightness.path}]:`, getDisplayBrightness(), "\n");
// Check for keyboard visibility
@@ -176,19 +184,19 @@ const sessionType = () => {
* @returns {bool} Returns true if the session matches the type.
*/
const session = (type) => {
return HARDWARE.session.type == type;
return HARDWARE.session.type === type;
};
/**
* Gets the model name using `/sys/firmware/devicetree/base/model` or `/sys/class/dmi/id/product_name`.
*
* @returns {string|null} The model name of the device or 'Generic' if not found.
* @returns {string} 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]);
return execSyncCommand("sudo", ["cat", path]) || "Generic";
}
}
return "Generic";
@@ -197,13 +205,13 @@ const getModel = () => {
/**
* Gets the vendor name using `/sys/class/dmi/id/board_vendor`.
*
* @returns {string|null} The vendor name of the device or 'Generic' if not found.
* @returns {string} 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]);
return execSyncCommand("sudo", ["cat", path]) || "Generic";
}
}
const model = getModel();
@@ -216,13 +224,13 @@ const getVendor = () => {
/**
* Gets the serial number using `/sys/firmware/devicetree/base/serial-number` or `/sys/class/dmi/id/product_serial`.
*
* @returns {string|null} The serial number of the device or '123456' if not found.
* @returns {string} 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]);
return execSyncCommand("sudo", ["cat", path]) || "123456";
}
}
return "123456";
@@ -234,7 +242,13 @@ const getSerialNumber = () => {
* @returns {string} The machine id of the system or '123456' if not found.
*/
const getMachineId = () => {
return execSyncCommand("cat", ["/etc/machine-id"]) || "123456";
const paths = ["/etc/machine-id"];
for (const path of paths) {
if (fs.existsSync(path)) {
return execSyncCommand("sudo", ["cat", path]) || "123456";
}
}
return "123456";
};
/**
@@ -303,6 +317,39 @@ const getProcessorTemperature = () => {
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`.
*
@@ -355,7 +402,7 @@ const getDisplayStatus = () => {
* 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.
* @param {Function} callback - A callback function that receives the output or error.
*/
const setDisplayStatus = (status, callback = null) => {
if (!HARDWARE.support.displayStatus) {
@@ -400,8 +447,8 @@ const getDisplayBrightnessMax = () => {
if (!HARDWARE.support.displayBrightness) {
return null;
}
const max = execSyncCommand("cat", [`${HARDWARE.display.brightness.path}/max_brightness`]);
if (max !== null) {
const max = fs.readFileSync(`${HARDWARE.display.brightness.path}/max_brightness`, "utf8").trim();
if (max) {
return parseInt(max, 10);
}
return null;
@@ -416,10 +463,10 @@ const getDisplayBrightness = () => {
if (!HARDWARE.support.displayBrightness) {
return null;
}
const brightness = execSyncCommand("cat", [`${HARDWARE.display.brightness.path}/brightness`]);
if (brightness !== null) {
const max = HARDWARE.display.brightness.max;
return Math.round((parseInt(brightness, 10) / max) * 100);
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;
};
@@ -431,7 +478,7 @@ const getDisplayBrightness = () => {
* 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.
* @param {Function} callback - A callback function that receives the output or error.
*/
const setDisplayBrightness = (brightness, callback = null) => {
if (!HARDWARE.support.displayBrightness) {
@@ -468,8 +515,8 @@ const getKeyboardVisibility = () => {
* This function takes a desired visibility ('ON' or 'OFF') and executes
* the appropriate command to show or hide the keyboard.
*
* @param {boolean} visibility - The desired visibility ('ON' or 'OFF').
* @param {function} callback - A callback function that receives the output or error.
* @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) {
@@ -507,7 +554,7 @@ const checkPackageUpgrades = () => {
* 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.
* @param {Function} callback - A callback function that receives the output or error.
*/
const shutdownSystem = (callback = null) => {
execAsyncCommand("sudo", ["shutdown", "-h", "now"], callback);
@@ -519,7 +566,7 @@ const shutdownSystem = (callback = null) => {
* 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.
* @param {Function} callback - A callback function that receives the output or error.
*/
const rebootSystem = (callback = null) => {
execAsyncCommand("sudo", ["reboot"], callback);
@@ -529,7 +576,7 @@ const rebootSystem = (callback = null) => {
* Checks if a process is running using `pidof`.
*
* @param {string} name - The process name to check.
* @returns {boolean} Returns true if the process runs.
* @returns {bool} Returns true if the process runs.
*/
const processRuns = (name) => {
try {
@@ -542,7 +589,7 @@ const processRuns = (name) => {
* Checks if a command is available using `which`.
*
* @param {string} name - The command name to check.
* @returns {boolean} Returns true if the command is available.
* @returns {bool} Returns true if the command is available.
*/
const commandExists = (name) => {
try {
@@ -573,8 +620,8 @@ const execSyncCommand = (cmd, args = []) => {
*
* @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.
* @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 = "";
@@ -615,7 +662,7 @@ const execAsyncCommand = (cmd, args = [], callback = null) => {
* @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.
* @param {Function} callback - A callback function that receives the output or error.
*/
const dbusCall = (path, method, values, callback = null) => {
const cmd = "dbus-send";
@@ -639,8 +686,8 @@ const dbusCall = (path, method, values, callback = null) => {
* 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.
* @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";
@@ -684,7 +731,7 @@ const dbusMonitor = (path, callback) => {
/**
* Helper function for asynchronous interval calls.
*
* @param {function} callback - An async callback function.
* @param {Function} callback - An async callback function.
* @param {number} ms - Sleep time in milliseconds.
*/
const interval = (callback, ms) => {
@@ -714,6 +761,8 @@ module.exports = {
getMemoryUsage,
getProcessorUsage,
getProcessorTemperature,
getBatteryLevelPath,
getBatteryLevel,
getDisplayStatusPath,
getDisplayStatus,
setDisplayStatus,
+816
View File
@@ -0,0 +1,816 @@
const mqtt = require("mqtt");
const hardware = require("./hardware");
const { app } = require("electron");
global.INTEGRATION = global.INTEGRATION || {
initialized: false,
status: null,
};
/**
* 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
.on("connect", () => {
console.log(`MQTT Connected: ${url.toString()}\n`);
// Init client controls
initShutdown();
initReboot();
initRefresh();
initKiosk();
initDisplay();
initKeyboard();
initPageNumber();
initPageUrl();
// Init client sensors
initModel();
initSerialNumber();
initHostName();
initUpTime();
initMemorySize();
initMemoryUsage();
initProcessorUsage();
initProcessorTemperature();
initBatteryLevel();
initPackageUpgrades();
initHeartbeat();
initLastActive();
// Integration initialized
INTEGRATION.initialized = true;
INTEGRATION.status = "Online";
})
.on("error", (error) => {
console.error("MQTT:", error);
INTEGRATION.status = "Offline";
});
// Update sensor states from notifiers
HARDWARE.display.notifiers.push(() => {
updateDisplay();
});
HARDWARE.keyboard.notifiers.push(() => {
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();
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.views[WEBVIEW.viewActive].webContents.reloadIgnoringCache();
}
})
.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);
hardware.update();
} else if (topic === config.brightness_command_topic) {
const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness);
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;
}
})
.subscribe(config.command_topic);
updatePageNumber();
};
/**
* Updates the page number via the mqtt connection.
*/
const updatePageNumber = () => {
const pageNumber = WEBVIEW.viewActive;
publishState("page_number", pageNumber);
};
/**
* 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].webContents.loadURL(url);
}
})
.subscribe(config.command_topic);
updatePageUrl();
};
/**
* Updates the page url via the mqtt connection.
*/
const updatePageUrl = () => {
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive];
const currentUrl = WEBVIEW.views[WEBVIEW.viewActive].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,
};
+709
View File
@@ -0,0 +1,709 @@
const path = require("path");
const hardware = require("./hardware");
const integration = require("./integration");
const { app, screen, nativeTheme, ipcMain, session, BaseWindow, WebContentsView } = require("electron");
global.WEBVIEW = global.WEBVIEW || {
initialized: false,
status: null,
locked: false,
pointer: {
position: {},
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.viewUrls = urls;
WEBVIEW.viewZoom = zoom;
WEBVIEW.viewTheme = theme;
WEBVIEW.widgetTheme = theme;
WEBVIEW.widgetEnabled = widget;
WEBVIEW.navigationTheme = theme;
WEBVIEW.navigationEnabled = widget;
nativeTheme.themeSource = WEBVIEW.viewTheme;
Object.defineProperty(WEBVIEW, "viewActive", {
get() {
return this._viewActive || 0;
},
set(v) {
this._viewActive = Math.max(0, Math.min(v, WEBVIEW.viewUrls.length - 1));
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);
view.webContents.loadURL(url);
WEBVIEW.views.push(view);
});
// 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();
domEvents();
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 = () => {
const title = `TouchKio - ${new URL(WEBVIEW.viewUrls[WEBVIEW.viewActive]).host}`;
console.log(`Update View: ${title} (${WEBVIEW.viewActive})`);
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 button
WEBVIEW.navigation.webContents.send("button-hidden", {
id: "pager",
hidden: WEBVIEW.viewUrls.length < 3,
});
// Update url text
WEBVIEW.navigation.webContents.send("input-text", {
id: "url",
text: currentUrl.startsWith("data:") ? "" : currentUrl,
placeholder: defaultUrl,
});
// 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,
});
// Resize webview
resizeView();
};
/**
* 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 next webview or cycles back to the first.
*/
const nextView = () => {
WEBVIEW.viewActive = (WEBVIEW.viewActive + 1) % WEBVIEW.views.length || 1;
};
/**
* Reloads the current or default 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 != defaultUrl) {
view.webContents.loadURL(defaultUrl);
} else {
view.webContents.reloadIgnoringCache();
}
};
/**
* Resizes and positions all webviews.
*/
const resizeView = () => {
const window = WEBVIEW.window.getBounds();
const navigation = WEBVIEW.navigation.getBounds();
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,
});
view.webContents.setZoomFactor(WEBVIEW.viewZoom);
});
// Update widget size
if (WEBVIEW.widgetEnabled) {
WEBVIEW.widget.setBounds({
x: window.width - 15,
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.display.notifiers.push(() => {
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 window touch events for activity tracking
setInterval(() => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const delta = Math.abs(now - then) / 1000;
const posNew = screen.getCursorScreenPoint();
const posOld = WEBVIEW.pointer.position;
// Cursor movement detected
if (posOld.x !== posNew.x || posOld.y !== posNew.y) {
WEBVIEW.pointer.time = now;
// Update integration sensor
if (delta > 30) {
console.log("Update Last Active");
integration.update();
}
} else if (delta > 60) {
const navigation = WEBVIEW.navigation.getBounds();
// Auto-hide navigation
if (navigation.height > 0) {
toggleNavigation();
}
}
WEBVIEW.pointer.position = posNew;
}, 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 - 15,
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";
hardware.setKeyboardVisibility(toggle);
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;
}
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");
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) => {
switch (button.id) {
case "pager":
nextView();
hardware.setKeyboardVisibility("OFF");
break;
case "home":
reloadView();
hardware.setKeyboardVisibility("OFF");
break;
case "backward":
historyBackward();
hardware.setKeyboardVisibility("OFF");
break;
case "forward":
historyForward();
hardware.setKeyboardVisibility("OFF");
break;
}
});
};
/**
* Register dom events and handler.
*/
const domEvents = () => {
WEBVIEW.views.forEach((view) => {
// 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" };
});
// 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;
if (WEBVIEW.viewActive === 0) {
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) {
nextView();
}
view.webContents.loadURL(errorHtml(code, text, url));
}
});
// Webview url changed
view.webContents.on("did-navigate-in-page", (e, url, mainframe) => {
if (mainframe) {
update();
}
});
view.webContents.on("did-navigate", () => {
update();
});
});
};
/**
* 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();
});
};
/**
* 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,
};
+9 -9
View File
@@ -3,25 +3,25 @@
"description": "Home Assistant Touch Kiosk",
"homepage": "https://github.com/leukipp/touchkio",
"author": "leukipp",
"version": "1.1.2",
"version": "1.2.0",
"license": "MIT",
"main": "index.js",
"scripts": {
"start": "electron . --web-url=https://demo.home-assistant.io",
"start": "electron . --web-url=https://demo.home-assistant.io --trace-warnings",
"build": "electron-forge make"
},
"prettier": {
"printWidth": 120
},
"devDependencies": {
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0",
"@electron-forge/publisher-github": "^7.6.0",
"electron": "^33.3.1",
"prettier": "^3.4.2"
"@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-deb": "^7.8.1",
"@electron-forge/maker-zip": "^7.8.1",
"@electron-forge/publisher-github": "^7.8.1",
"electron": "^37.2.0",
"prettier": "^3.6.2"
},
"dependencies": {
"mqtt": "^5.10.3"
"mqtt": "^5.13.2"
}
}
-763
View File
@@ -1,763 +0,0 @@
const mqtt = require("mqtt");
const hardware = require("./hardware");
const { app } = require("electron");
global.INTEGRATION = global.INTEGRATION || {
initialized: false,
status: null,
};
/**
* Initializes the integration with the provided arguments.
*
* @returns {boolean} 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();
}
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 deviceId = serialNumberSuffix.toUpperCase().replace(/[^A-Z0-9]/g, "");
const deviceName = hostName.charAt(0).toUpperCase() + hostName.slice(1);
// Init globals
INTEGRATION.node = `rpi_${deviceId}`;
INTEGRATION.discovery = discovery;
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
.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);
initKeyboard(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);
initPackageUpgrades(INTEGRATION.client);
initHeartbeat(INTEGRATION.client);
initLastActive(INTEGRATION.client);
// Integration initialized
INTEGRATION.initialized = true;
INTEGRATION.status = "Online";
})
.on("error", (error) => {
console.error("MQTT:", error);
INTEGRATION.status = "Offline";
});
// Update sensor states from notifiers
HARDWARE.display.notifiers.push(() => {
updateDisplay(INTEGRATION.client);
});
HARDWARE.keyboard.notifiers.push(() => {
updateKeyboard(INTEGRATION.client);
});
// Update time sensors periodically (30s)
setInterval(() => {
updateHeartbeat(INTEGRATION.client);
updateLastActive(INTEGRATION.client);
}, 30 * 1000);
// Update system sensors periodically (1min)
setInterval(() => {
update();
}, 60 * 1000);
// Update package sensors periodically (60min)
setInterval(() => {
updatePackageUpgrades(INTEGRATION.client);
}, 3600 * 1000);
return true;
};
/**
* Updates the shared integration properties.
*/
const update = async () => {
if (!INTEGRATION.initialized) {
return;
}
// Update client sensors
updateKiosk(INTEGRATION.client);
updateUpTime(INTEGRATION.client);
updateLastActive(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`;
const config = {
name: "Shutdown",
unique_id: `${INTEGRATION.node}_shutdown`,
command_topic: `${root}/execute`,
icon: "mdi:power",
device: INTEGRATION.device,
};
client
.publish(`${root}/config`, JSON.stringify(config), { 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`;
const config = {
name: "Reboot",
unique_id: `${INTEGRATION.node}_reboot`,
command_topic: `${root}/execute`,
icon: "mdi:restart",
device: INTEGRATION.device,
};
client
.publish(`${root}/config`, JSON.stringify(config), { 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`;
const config = {
name: "Refresh",
unique_id: `${INTEGRATION.node}_refresh`,
command_topic: `${root}/execute`,
icon: "mdi:web-refresh",
device: INTEGRATION.device,
};
client
.publish(`${root}/config`, JSON.stringify(config), { 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`;
const config = {
name: `Kiosk`,
unique_id: `${INTEGRATION.node}_kiosk`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
options: ["Framed", "Fullscreen", "Maximized", "Minimized", "Terminated"],
icon: "mdi:overscan",
device: INTEGRATION.device,
};
client
.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
.on("message", (topic, message) => {
if (topic === `${root}/set`) {
const status = message.toString();
console.log("Set 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`;
if (kiosk !== null) {
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) => {
if (!HARDWARE.support.displayStatus) {
return;
}
const root = `${INTEGRATION.discovery}/light/${INTEGRATION.node}/display`;
const config = {
name: `Display`,
unique_id: `${INTEGRATION.node}_display`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
icon: "mdi:monitor-shimmer",
platform: "light",
device: INTEGRATION.device,
};
if (HARDWARE.support.displayBrightness) {
config.brightness_scale = 100;
config.brightness_command_topic = `${root}/brightness/set`;
config.brightness_state_topic = `${root}/brightness/status`;
}
client
.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
.on("message", (topic, message) => {
if (topic === `${root}/set`) {
const status = message.toString();
console.log("Set Display Status:", status);
hardware.setDisplayStatus(status);
hardware.update();
} else if (topic === `${root}/brightness/set`) {
const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness);
hardware.update();
}
})
.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`;
if (status !== null) {
client.publish(`${root}/status`, `${status}`, { qos: 1, retain: true });
}
if (brightness !== null) {
client.publish(`${root}/brightness/status`, `${brightness}`, { qos: 1, retain: true });
}
};
/**
* Initializes the keyboard visibility and handles the execute logic.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initKeyboard = (client) => {
if (!HARDWARE.support.keyboardVisibility) {
return;
}
const root = `${INTEGRATION.discovery}/switch/${INTEGRATION.node}/keyboard`;
const config = {
name: `Keyboard`,
unique_id: `${INTEGRATION.node}_keyboard`,
command_topic: `${root}/set`,
state_topic: `${root}/status`,
icon: "mdi:keyboard-close-outline",
device: INTEGRATION.device,
};
client
.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true })
.on("message", (topic, message) => {
if (topic === `${root}/set`) {
const status = message.toString();
console.log("Set Keyboard Visibility:", status);
hardware.setKeyboardVisibility(status);
switch (status) {
case "OFF":
WEBVIEW.window.restore();
WEBVIEW.window.unmaximize();
WEBVIEW.window.setFullScreen(true);
break;
case "ON":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.maximize();
break;
}
}
})
.subscribe(`${root}/set`);
updateKeyboard(client);
};
/**
* Updates the keyboard visibility via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateKeyboard = (client) => {
const visibility = hardware.getKeyboardVisibility();
const root = `${INTEGRATION.discovery}/switch/${INTEGRATION.node}/keyboard`;
if (visibility !== null) {
client.publish(`${root}/status`, `${visibility}`, { qos: 1, retain: true });
}
};
/**
* Initializes the model sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initModel = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/model`;
const config = {
name: `Model`,
unique_id: `${INTEGRATION.node}_model`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:raspberry-pi",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (model !== null) {
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`;
const config = {
name: `Serial Number`,
unique_id: `${INTEGRATION.node}_serial_number`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:hexadecimal",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (serialNumber !== null) {
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`;
const config = {
name: `Host Name`,
unique_id: `${INTEGRATION.node}_host_name`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:console-network",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (hostName !== null) {
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`;
const config = {
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,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (upTime !== null) {
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`;
const config = {
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,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (memorySize !== null) {
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`;
const config = {
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,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (memoryUsage !== null) {
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`;
const config = {
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,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (processorUsage !== null) {
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`;
const config = {
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,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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`;
if (processorTemperature !== null) {
client.publish(`${root}/status`, `${processorTemperature}`, { qos: 1, retain: true });
}
};
/**
* Initializes the package upgrades sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initPackageUpgrades = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/package_upgrades`;
const config = {
name: `Package Upgrades`,
unique_id: `${INTEGRATION.node}_package_upgrades`,
state_topic: `${root}/status`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value }}",
icon: "mdi:package-down",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
updatePackageUpgrades(client);
};
/**
* Updates the package upgrades sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updatePackageUpgrades = (client) => {
const packages = hardware.checkPackageUpgrades();
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/package_upgrades`;
if (packages !== null) {
const attributes = {
total: packages.length,
packages: packages.map((pkg) => pkg.replace(/\s*\[.*?\]\s*/g, "").trim()),
};
client.publish(`${root}/status`, `${attributes.total}`, { qos: 1, retain: true });
client.publish(`${root}/attributes`, JSON.stringify(attributes), { qos: 1, retain: true });
}
};
/**
* Initializes the heartbeat sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initHeartbeat = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/heartbeat`;
const config = {
name: `Heartbeat`,
unique_id: `${INTEGRATION.node}_heartbeat`,
state_topic: `${root}/status`,
value_template: "{{ value }}",
icon: "mdi:heart-flash",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { 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 });
};
/**
* Initializes the last active sensor.
*
* @param {Object} client - Instance of the mqtt client.
*/
const initLastActive = (client) => {
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/last_active`;
const config = {
name: `Last Active`,
unique_id: `${INTEGRATION.node}_last_active`,
state_topic: `${root}/status`,
value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min",
icon: "mdi:gesture-tap-hold",
device: INTEGRATION.device,
};
client.publish(`${root}/config`, JSON.stringify(config), { qos: 1, retain: true });
updateLastActive(client);
};
/**
* Updates the last active sensor via the mqtt connection.
*
* @param {Object} client - Instance of the mqtt client.
*/
const updateLastActive = (client) => {
const now = new Date();
const then = WEBVIEW.pointer.time;
const lastActive = Math.abs(now - then) / (1000 * 60);
const root = `${INTEGRATION.discovery}/sensor/${INTEGRATION.node}/last_active`;
client.publish(`${root}/status`, `${lastActive}`, { qos: 1, retain: true });
};
module.exports = {
init,
update,
};
-325
View File
@@ -1,325 +0,0 @@
const path = require("path");
const hardware = require("./hardware");
const integration = require("./integration");
const { app, screen, nativeTheme, ipcMain, BaseWindow, WebContentsView } = require("electron");
global.WEBVIEW = global.WEBVIEW || {
initialized: false,
status: null,
locked: false,
pointer: {
position: {},
time: new Date(),
},
};
/**
* Initializes the webview with the provided arguments.
*
* @returns {boolean} Returns true if the initialization was successful.
*/
const init = async () => {
if (!ARGS.web_url) {
console.error("Please provide the '--web-url' parameter");
return app.quit();
}
if (!/^https?:\/\//.test(ARGS.web_url)) {
console.error("Please provide the '--web-url' parameter with http(s)");
return app.quit();
}
const url = new URL(ARGS.web_url);
const theme = ["light", "dark"].includes(ARGS.web_theme) ? ARGS.web_theme : "dark";
const zoom = !isNaN(parseFloat(ARGS.web_zoom)) ? parseFloat(ARGS.web_zoom) : 1.25;
const widget = ARGS.web_widget ? ARGS.web_widget === "true" : true;
const fullscreen = ARGS.app_debug !== "true";
// Init global layout
WEBVIEW.viewZoom = zoom;
WEBVIEW.viewTheme = theme;
WEBVIEW.widgetTheme = theme;
WEBVIEW.widgetEnabled = widget;
nativeTheme.themeSource = WEBVIEW.viewTheme;
// Init global root window
WEBVIEW.window = new BaseWindow({
title: `TouchKio - ${url.host}`,
icon: path.join(__dirname, "..", "img", "icon.png"),
});
WEBVIEW.window.setMenuBarVisibility(false);
WEBVIEW.window.setFullScreen(fullscreen);
if (!fullscreen) {
WEBVIEW.window.maximize();
}
// Init global webview
WEBVIEW.view = new WebContentsView({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
},
});
WEBVIEW.window.contentView.addChildView(WEBVIEW.view);
WEBVIEW.view.webContents.loadURL(url.toString());
WEBVIEW.view.setBackgroundColor("#FFFFFFFF");
// Init global widget
WEBVIEW.widget = new WebContentsView({
transparent: true,
webPreferences: {
nodeIntegration: true,
contextIsolation: false,
},
});
WEBVIEW.window.contentView.addChildView(WEBVIEW.widget);
WEBVIEW.widget.webContents.loadFile(path.join(__dirname, "widget.html"));
WEBVIEW.widget.setBackgroundColor("#00000000");
// Register events
windowEvents();
widgetEvents();
domEvents();
appEvents();
return true;
};
/**
* Updates the shared webview properties.
*/
const update = async () => {
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 widget status
if (!HARDWARE.support.keyboardVisibility) {
WEBVIEW.widget.webContents.send("button-hide", { id: "keyboard" });
}
// Update integration sensor
console.log("Update Kiosk Status:", WEBVIEW.status);
integration.update();
};
/**
* Register window events and handler.
*/
const windowEvents = () => {
// Handle window resize events
const resize = () => {
const window = WEBVIEW.window.getBounds();
const widget = { width: 60, height: 200 };
// Update view size
WEBVIEW.view.setBounds({
x: 0,
y: 0,
width: window.width,
height: window.height,
});
WEBVIEW.view.webContents.setZoomFactor(WEBVIEW.viewZoom);
WEBVIEW.view.webContents.focus();
// Update widget size
if (WEBVIEW.widgetEnabled) {
WEBVIEW.widget.setBounds({
x: window.width - 15,
y: parseInt(window.height / 2 - widget.height / 2),
width: widget.width,
height: widget.height,
});
WEBVIEW.widget.webContents.send("data-theme", { theme: "hidden" });
}
};
WEBVIEW.window.on("ready-to-show", resize);
WEBVIEW.window.on("resize", resize);
resize();
// Handle window focus events
WEBVIEW.window.on("focus", () => {
if (WEBVIEW.locked) {
hardware.setDisplayStatus("ON");
WEBVIEW.window.blur();
}
WEBVIEW.locked = false;
});
HARDWARE.display.notifiers.push(() => {
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 window touch events for activity tracking
setInterval(() => {
const posOld = WEBVIEW.pointer.position;
const posNew = screen.getCursorScreenPoint();
if (posOld.x !== posNew.x || posOld.y !== posNew.y) {
const now = new Date();
const then = WEBVIEW.pointer.time;
// Update integration sensor
WEBVIEW.pointer.time = now;
if (Math.abs(now - then) / 1000 > 30) {
console.log("Update Last Active");
integration.update();
}
}
WEBVIEW.pointer.position = posNew;
}, 1 * 1000);
};
/**
* Register 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 - 15,
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 "refresh":
WEBVIEW.view.webContents.reloadIgnoringCache();
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);
}
break;
case "minimize":
WEBVIEW.window.restore();
WEBVIEW.window.setFullScreen(false);
WEBVIEW.window.minimize();
break;
case "keyboard":
const toggle = hardware.getKeyboardVisibility() === "ON" ? "OFF" : "ON";
hardware.setKeyboardVisibility(toggle);
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;
}
break;
}
});
};
/**
* Register dom events and handler.
*/
const domEvents = () => {
// Enable webview touch emulation
WEBVIEW.view.webContents.debugger.attach("1.1");
WEBVIEW.view.webContents.debugger.sendCommand("Emulation.setEmitTouchEventsForMouse", {
configuration: "mobile",
enabled: true,
});
// Disable webview hyperlinks
WEBVIEW.view.webContents.setWindowOpenHandler(() => {
return { action: "deny" };
});
// Remove webview scrollbar
WEBVIEW.view.webContents.on("dom-ready", () => {
WEBVIEW.view.webContents.insertCSS("::-webkit-scrollbar { display: none; }");
});
// Webview fully loaded
WEBVIEW.view.webContents.on("did-finish-load", () => {
if (ARGS.app_debug === "true") {
WEBVIEW.view.webContents.openDevTools();
}
WEBVIEW.initialized = true;
update();
});
};
/**
* 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();
});
};
module.exports = {
init,
update,
};
+303 -432
View File
File diff suppressed because it is too large Load Diff