63 Commits
Author SHA1 Message Date
leukipp 1e91b7aa27 build: update version 2025-12-01 08:57:00 +01:00
leukipp eb4b101f45 docs: update hardware 2025-12-01 08:55:38 +01:00
leukipp 290e524901 build: update dependencies 2025-11-27 07:47:06 +01:00
leukipp 4b010925ed docs: update readme 2025-11-26 16:51:15 +01:00
Loryan Strant 910001f0e1 Add DNS resolution notes to README (#134)
* Add DNS resolution notes to README

Added DNS resolution notes and instructions for using system resolver.
2025-11-26 15:11:41 +01:00
leukipp 61628b1864 feat: add mqtt screenshot image #133 2025-11-25 17:12:43 +01:00
leukipp 1a79ffd855 feat: ddcutil display brightness support #101 2025-11-14 14:39:27 +01:00
leukipp 5423239e62 fix: handle renderer crashes #115 2025-11-14 14:02:15 +01:00
leukipp 44a4fa9d2d ref: allow null logging 2025-11-11 11:35:59 +01:00
leukipp 26494e6844 fix: handle invalid file read #113 2025-11-10 19:36:29 +01:00
leukipp 4f8a9c307c fix: graceful quit on shutdown #125 2025-11-10 14:46:15 +01:00
leukipp a3860ebd69 feat: remember zoom and theme per page #108 2025-11-10 11:25:53 +01:00
leukipp b3a787d03d ref: register argument updates 2025-11-10 11:21:33 +01:00
leukipp 41e4c2d434 ref: integer parsing 2025-11-10 11:20:36 +01:00
leukipp 77cb962e6f ref: app exit codes 2025-10-30 14:31:49 +01:00
leukipp c85055a222 feat: improved swipe paging #64 2025-10-30 14:30:53 +01:00
leukipp 81b4c30aa2 fix: window race conditions #102 2025-10-27 16:26:20 +01:00
leukipp e6d8c537f2 ref: whoopsie logging 2025-10-27 16:21:22 +01:00
leukipp 0c51c1c1ff ref: content security policy 2025-10-26 13:46:21 +01:00
leukipp 45ea309f3b ref: add debug logging 2025-10-26 13:44:59 +01:00
leukipp e195a56739 build: update version 2025-10-10 21:29:38 +02:00
leukipp d86a5385a8 docs: update readme 2025-10-10 19:11:29 +02:00
leukipp 3ce1265c8a docs: update hardware 2025-10-10 19:10:43 +02:00
leukipp c2bf551834 ref: update images 2025-10-10 19:08:29 +02:00
leukipp 785a97721a build: update dependencies 2025-10-09 14:19:57 +02:00
leukipp 05c9d98b61 feat: add mqtt version diagnostic #91 2025-10-09 13:43:20 +02:00
leukipp 9c26d9d8bb ref: add mqtt attributes 2025-10-09 13:09:30 +02:00
leukipp ad7d3fd3ff feat: early app updates #96 2025-10-08 20:47:59 +02:00
leukipp f666ed57ab ref: widget focus handling 2025-10-08 20:13:48 +02:00
leukipp c0b1a6e9ce feat: add mqtt error diagnostic #91 2025-10-07 21:48:49 +02:00
leukipp 19e51e349c fix: use mqtt last will #84 2025-10-07 09:59:48 +02:00
leukipp 5d012bd637 feat: extended theme switching #87 2025-10-07 09:17:57 +02:00
leukipp 01c7411ddc feat: use box for mqtt zoom #90 2025-10-06 19:17:35 +02:00
leukipp 3e4d96536f feat: dynamic status bar #94 2025-10-06 18:55:51 +02:00
leukipp 4bcb2c4e89 feat: auto on-screen keyboard #85 2025-09-30 21:26:39 +02:00
leukipp b2b87888e4 ref: keyboard window status handling 2025-09-30 17:31:00 +02:00
leukipp 164ef2838c docs: add sponsor 2025-09-29 10:20:40 +02:00
leukipp ae2633800d ref: allow electron flags 2025-09-29 10:10:12 +02:00
leukipp 552f0e19a4 feat: mqtt audio volume control #82 2025-09-26 13:47:15 +02:00
leukipp 890c84d69d Merge branch 'develop' of https://github.com/leukipp/touchkio into develop 2025-09-21 22:47:10 +02:00
leukipp ac69dee387 ref: improve error handling 2025-09-21 22:47:02 +02:00
leukipp ac62dc17ce build: update version 2025-09-19 22:07:45 +02:00
leukipp f2f80e7d52 fix: mqtt config removal #79 2025-09-19 22:07:14 +02:00
leukipp 10e7ff078a build: update version 2025-09-19 16:49:12 +02:00
leukipp e677fff160 docs: update readme 2025-09-19 16:36:57 +02:00
leukipp 63db26611e build: update dependencies 2025-09-19 15:57:13 +02:00
leukipp 5ec6577b5a fix: option to ignore certificate errors #76 2025-09-19 15:52:09 +02:00
leukipp 0359742e70 docs: update readme 2025-09-11 08:55:00 +02:00
leukipp b70f4074e5 docs: add hardware 2025-09-10 16:43:24 +02:00
leukipp a4c300407b ref: remove unsupported mqtt config 2025-09-10 16:00:26 +02:00
leukipp 758f64ced4 ref: add cpu thermal zone 2025-09-10 15:49:22 +02:00
leukipp 7ab803d4ca ref: app update summary 2025-09-09 20:50:56 +02:00
leukipp 092697850d build: compress zip 2025-09-09 19:59:30 +02:00
leukipp 422e92dd89 build: update version 2025-09-09 19:41:09 +02:00
leukipp a15c0fc91d docs: update readme 2025-09-09 19:33:33 +02:00
leukipp 358fa3ca9c ref: use display status callback 2025-09-09 17:25:56 +02:00
leukipp 10722fa217 build: update build.json 2025-09-08 21:31:01 +02:00
leukipp 7267fafb97 ref: add console logs 2025-09-08 20:03:23 +02:00
leukipp b5e95936dc ref: obfuscate logged password 2025-09-08 18:58:29 +02:00
leukipp 047b38a41b feat: extended logging #73 2025-09-08 17:31:42 +02:00
leukipp a93fbb2ad3 build: update dependencies 2025-09-08 17:18:16 +02:00
leukipp 0b927a3ef6 fix: touch event handling for inactive screens #61 2025-09-08 16:23:04 +02:00
leukipp d35cf27de9 fix: update last active only on touch #71 2025-09-08 09:56:27 +02:00
17 changed files with 3231 additions and 905 deletions
+1
View File
@@ -0,0 +1 @@
github: leukipp
+221 -8
View File
@@ -29,25 +29,238 @@ If you are running Linux with a graphical user interface (Wayland or X11), you s
### HDMI ### HDMI
| Device | System | Display | Status | | Device | System | Display | Status |
| ------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------ | | ------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------ |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [LAFVIN 5" Touch Display (800x480)](https://www.amazon.de/gp/product/B0BWJ8YP7S) | 🟨 | | Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [LAFVIN Touch Display 5" (800x480)](https://www.amazon.de/gp/product/B0BWJ8YP7S) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 8" CAPLCD (768x1024)](https://www.waveshare.com/wiki/8inch_768x1024_LCD) | 🟨 | | Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 7EP-CAPLCD 7" (1280x800)](https://www.waveshare.com/7ep-caplcd.htm) | 🟨 |
| Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare 7" 7EP-CAPLCD (1280x800)](https://www.waveshare.com/7ep-caplcd.htm) | 🟨 | | Raspberry Pi 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare CAPLCD 8" (768x1024)](https://www.waveshare.com/wiki/8inch_768x1024_LCD) | 🟨 |
| 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 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Hannspree HT225 21.5" (1920x1080)](https://www.hannspree.eu/product/HT-225-HPB) | 🟨 |
| Raspberry Pi 400 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [UPerfect Vertical Touch 15.6" (1920x1080)](https://uperfect.com/products/uperfect-y-vertical-monitor-15-6) | 🟨 |
| Raspberry Pi CM 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Lenovo ThinkCentre 23.8" (1920x1080)](https://psref.lenovo.com/Detail/ThinkCentre_Tiny_In_One_24_Gen_5?M=12NBGAT1EU) | 🟨 | | Raspberry Pi CM 4 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Lenovo ThinkCentre 23.8" (1920x1080)](https://psref.lenovo.com/Detail/ThinkCentre_Tiny_In_One_24_Gen_5?M=12NBGAT1EU) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [GeeekPi 10.1" Capacitive Touch (1280x800)](https://www.amazon.nl/dp/B0DHV6DZC1) | 🟨 | | Raspberry Pi 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 | [Prechen Portable Touch 18.5" FHD (1920x1080)](https://www.amazon.de/dp/B0CT2KLDBQ) | 🟨 | | Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [GeeekPi Capacitive Touch 10.1" (1280x800)](https://www.amazon.nl/dp/B0DHV6DZC1) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Akntzcs Portable Touch 16" HD (1920x1200)](https://www.amazon.com/dp/B0CTGW6MQ6) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [ELECROW Portable Monitor 10.1" (1280x800)](https://www.amazon.co.uk/dp/B0BHHQLKPY) | 🟨 | | Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [ELECROW Portable Monitor 10.1" (1280x800)](https://www.amazon.co.uk/dp/B0BHHQLKPY) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Waveshare Capacitive Touch 14" (2160x1440)](https://www.waveshare.com/14inch-2160x1440-lcd.htm) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Akntzcs Portable Touch HD 16" (1920x1200)](https://www.amazon.com/dp/B0CTGW6MQ6) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | [Prechen Portable Touch FHD 18.5" (1920x1080)](https://www.amazon.de/dp/B0CT2KLDBQ) | 🟨 |
| Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | Generic Non-Touch | 🟨 | | Raspberry Pi 5 (arm64) | Raspberry Pi OS (64-bit), Wayland, X11 | Generic Non-Touch | 🟨 |
| Generic PC (x64) | Debian KDE (64-bit), Wayland, X11 | Generic Non-Touch | 🟧 | | Generic PC (x64) | Debian KDE (64-bit), Wayland, X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu XFCE (64-bit), X11 | Generic Non-Touch | 🟧 |
| Generic PC (x64) | Ubuntu GNOME (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 | 🟥 | | Generic PC (x64) | Ubuntu GNOME (64-bit), Wayland | Generic Non-Touch | 🟥 |
## Features
**Minimal features** are designed to run on any system without issues:
- 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:
- If your hardware is not fully compatible there should be no crashes, but you may miss some sensors.
Hardware support is verified during application startup and can be checked in the terminal or in the log file under the `Supported` section.
The necessary requirements for MQTT sensors to work are listed here:
| Name | Requirements | References |
| -------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| App (Update) | Requires `sudo` rights, `.deb` install and `touchkio.service` running . | [#70](https://github.com/leukipp/touchkio/issues/70), [#77](https://github.com/leukipp/touchkio/issues/77) |
| Display (Status) | Working `wlopm`, `kscreen-doctor` or `xset` command. | [#38](https://github.com/leukipp/touchkio/issues/38), [#57](https://github.com/leukipp/touchkio/issues/57) |
| Display (Brightness) | Requires `sudo` rights, device under `/sys/class/backlight/*/brightness` exists or working `ddcutil` command. | [#30](https://github.com/leukipp/touchkio/issues/30), [#101](https://github.com/leukipp/touchkio/issues/101) |
| Keyboard | Raspberry Pi OS (Wayland) with `squeekboard` running. | [#7](https://github.com/leukipp/touchkio/issues/7), [#85](https://github.com/leukipp/touchkio/issues/85) |
| Battery | Device under `/sys/class/power_supply/*/capacity` exists. | [#33](https://github.com/leukipp/touchkio/issues/33) |
| Volume | Device (non-dummy) `pactl get-default-sink` exists. | [#82](https://github.com/leukipp/touchkio/issues/82) |
| Reboot | Requires password-less `sudo` rights. | [#39](https://github.com/leukipp/touchkio/issues/39) |
| Shutdown | Requires password-less `sudo` rights. | [#39](https://github.com/leukipp/touchkio/issues/39) |
## FAQ
### Operating System
<details><summary>I have installed Ubuntu GNOME.</summary>
- On some Debian based systems (e.g. Ubuntu GNOME), the display status control is only available when using X11 (`xset`).
- GNOME is the least supported window manager.
- It's recommended to switch to KDE wayland, if you want better support for display status control (`kscreen-doctor`).
</details>
<details><summary>I have installed Raspberry Pi OS on a RPI3.</summary>
- Raspberry Pi 3 devices have produced a bunch of issues in the past.
- [RPI 3B wayland rendering issue](ttps://github.com/leukipp/touchkio/issues/104).
- [RPI 3b+ thermostat card issue](https://github.com/leukipp/touchkio/issues/17).
- [Webpage cards don't work (Pi3b+)](https://github.com/leukipp/touchkio/issues/24).
- It looks like the GPU is not properly supported by Electron.
- Running `touchkio --disable-gpu` may improve the situation, but upgrading the hardware is the best solution.
</details>
<details><summary>I want to update APT packages automatically.</summary>
- The **Package Upgrades** MQTT sensor will show if `apt` package upgrades are available, but updates are [intentionally](https://github.com/leukipp/touchkio/issues/7) not triggered by TouchKio.
- It's recommended to keep your system up to date for security and compatibility, as TouchKio primarily supports the latest OS versions.
- Consider configuring `sudo apt install unattended-upgrades` for automatic updates.
</details>
### Display
<details><summary>Display status can't be controlled through MQTT.</summary>
- The following commands are currently implemented to modify the display status. Make sure that one of these works for your display when you run it directly on the terminal, otherwise the MQTT switch will not work either.
- `wlopm --[on,off] \*` (Raspberry Pi OS, Wayland)
- `kscreen-doctor --dpms [on,off]` (Debian KDE, Wayland)
- `xset dpms force [on,off]` (Generic, X11)
</details>
<details><summary>Display brightness can't be controlled through MQTT.</summary>
- Have a look at the [features](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md#features) section to check if all requirements are fulfilled.
- The **Display** MQTT control is a light entity with brightness support. So make sure to to [click on the entity](https://github.com/leukipp/touchkio/issues/75) to check for brightness support.
- HDMI screens typically do not offer brightness control out of the box, so additional setup steps are required.
- Brightness support for [ddcutil](https://github.com/leukipp/touchkio/issues/101#issuecomment-3521247263) is build-in and checked on application startup.
- Alternatively installing [ddcci-driver-linux](https://github.com/leukipp/touchkio/issues/132#issue-3659009749) or [ddcci-dkms](https://github.com/leukipp/touchkio/issues/101#issuecomment-3571523927) may also work.
</details>
<details><summary>Turning HDMI screen off via MQTT causes it to turn back on.</summary>
- There are certain HDMI screens (e.g. Viewsonic) where the MQTT display status command doesn't work as expected.
- The screen [immediately turns on again](https://github.com/leukipp/touchkio/issues/38), which may be caused by incompatible HDMI cables being used.
</details>
### Touch
<details><summary>Automated screen blanking on inactivity.</summary>
- You can use Raspberry Pi's build-in [screen blanking](https://www.raspberrypi.com/documentation/computers/configuration.html#screen-blanking-3) functionality, however, if the screen is turned on through Home Assistant after being automatically turned off, it will remain on indefinitely.
- It's recommended to either use the built-in screen blanking feature or implement a Home Assistant [automation](https://www.home-assistant.io/docs/automation/basics) (e.g. presence detection or **Last Active** MQTT sensor) to manage the screen status.
</details>
<details><summary>Touch in multitouch mode not waking the screen.</summary>
- It's recommended to use **Mouse Emulation** for touch screens, otherwise touch based [screen wake-up](https://github.com/leukipp/touchkio/issues/127) from display off state will fail, especially if build-in [screen blanking](https://www.raspberrypi.com/documentation/computers/configuration.html#screen-blanking-3) is disabled.
</details>
<details><summary>Touch events may propagate through a display that is turned off.</summary>
- It's recommended to use **Mouse Emulation** for touch screens, otherwise Home Assistant actions could be triggered [unintentionally](https://github.com/leukipp/touchkio/issues/61) on touch.
</details>
### Network
<details><summary>Connecting via https/mqtts with a self-signed certificate.</summary>
- When connecting to a service with a [custom certificate](https://github.com/leukipp/touchkio/issues/42#issuecomment-3041870215), ensure the specified FQDN matches.
- Using the `--ignore-certificate-errors` flag is [not recommended](https://github.com/leukipp/touchkio/issues/76) for browsing external sites.
</details>
<details><summary>Local DNS entries in /etc/hosts are ignored.</summary>
- Electron bypasses the system resolver stack, ignoring `/etc/hosts` and local DNS servers.
- To use the system resolver, modify `~/.config/systemd/user/touchkio.service` to disable these features:
```bash
ExecStart=/usr/bin/touchkio --disable-features=UseDNSHttps,AsyncDns
```
</details>
<details><summary>During VNC access the display turns gray.</summary>
- On Raspberry Pi OS the display command may fail with `ERROR: Setting power mode for output '[DSI-*,HDMI-*]' failed`.
- This can happen if you have `wayvnc` running for remote access and is caused by a [known bug](https://github.com/leukipp/touchkio/issues/78).
</details>
### Media
<details><summary>I need Music and Voice Assistant.</summary>
- Not planned, look for [alternatives](https://github.com/leukipp/touchkio/issues/88#issuecomment-3366659265).
</details>
<details><summary>I need a camera stream from the device.</summary>
- Not planned, look for [alternatives](https://github.com/leukipp/touchkio/issues/130#issuecomment-3591770594).
</details>
<details><summary>I need additional MQTT entities to control hardware.</summary>
- Not planned, look for [alternatives](https://github.com/leukipp/touchkio/issues/138#issuecomment-3591784725).
</details>
### Dependencies
<details><summary>Some of the MQTT controls are missing.</summary>
- Certain [features](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md#features) from the MQTT integration may require elevated privileges to work correctly.
- Test if your local user has the [necessary permissions](https://github.com/leukipp/touchkio/issues/39) to run `sudo -n true` without being [prompted for a password](https://github.com/leukipp/touchkio/issues/116#issuecomment-3471566411).
</details>
<details><summary>The ddcutil command doesn't seem to be used.</summary>
- Currently `ddcutil` is only used for brightness control of HDMI screens.
- Make sure that your screen is supporting [continuous](https://github.com/leukipp/touchkio/issues/101#issuecomment-3521247263) adjustments of brightness values.
</details>
<details><summary>The on-screen keyboard doesn't automatically pop-out.</summary>
- Most of the available on-screen keyboards will not render above any [fullscreen window](https://forums.raspberrypi.com/viewtopic.php?p=2327148).
- There is a [workaround](https://github.com/leukipp/touchkio/issues/85) for `squeekboard`, which will only work on Raspberry Pi OS.
- If the on-screen keyboard still doesn't [automatically pop-out](https://github.com/leukipp/touchkio/issues/4) when entering a text field inside the webview you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the visibility.
</details>
### Errors
<details><summary>Terminal is full with "ERROR:gbm_wrapper.cc" messages.</summary>
- 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.
</details>
<details><summary>CPU and RAM usage increases without reason.</summary>
- This was [especially observed](https://github.com/leukipp/touchkio/issues/123) when the screen is permanently on and renders a dashboard with interactive elements (e.g. custom lovelace cards).
- Switching to another **Page Url** or turning the **Display** off during idle times improves resource usage.
- Alternatively a timed **Refresh** of the webview via MQTT can also help.
</details>
<details><summary>Error message "GPU Process abnormal-exit".</summary>
- _GPU Process abnormal-exit (code 512)_ means that the Electron GPU process crashed, probably caused by some issues inside the webview.
- Disabling the GPU via `touchkio --disable-gpu` may help.
</details>
<details><summary>Error message "Render Process killed".</summary>
- _Render Process killed (code 9)_ will lead to a temporary [white screen](https://github.com/leukipp/touchkio/issues/115).
- The problem likely stems from Electron’s calculated RAM limit (~2GB) and the dashboard using too much memory.
- Although there are flags to [increase the limit](https://github.com/leukipp/touchkio/issues/36#issuecomment-3406441947), a standard webview shouldn’t require that much RAM.
</details>
## Contributions ## Contributions
In case your hardware is not listed above don't worry, give it a try. In case your hardware is not listed above don't worry, give it a try.
Running `touchkio --web-url=https://demo.home-assistant.io` will most likely just work. Running `touchkio --web-url=https://demo.home-assistant.io` will most likely just work.
The only problems that may arise are when controlling the display or keyboard via the Home Assistant integration. The only problems that may arise are when controlling the display via the Home Assistant integration.
There is a possibility to test `pre-releases` if you would like to try upcoming versions before the official release is published.
These early builds are available to a smaller group of users who have chosen to take part in testing.
If you are interested in long-term testing, check out the related [help request](https://github.com/leukipp/touchkio/issues/96).
When a [pre-release](https://github.com/leukipp/touchkio/releases) is available and you want to test it on-demand, run:
```bash
bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh) update early
```
- If you encounter any problems, please create a new [issue](https://github.com/leukipp/touchkio/issues). - If you encounter any problems, please create a new [issue](https://github.com/leukipp/touchkio/issues).
- If you encounter any problems and are able to fix it yourself, feel free to create a [pull request](https://github.com/leukipp/touchkio/pulls). - If you encounter any problems and are able to fix it yourself, feel free to create a [pull request](https://github.com/leukipp/touchkio/pulls).
+43 -39
View File
@@ -1,4 +1,10 @@
# TouchKio # TouchKio
[![build](https://img.shields.io/github/actions/workflow/status/leukipp/touchkio/release.yaml?style=flat-square)](https://github.com/leukipp/touchkio/actions)
[![date](https://img.shields.io/github/release-date/leukipp/touchkio?style=flat-square)](https://github.com/leukipp/touchkio/releases)
[![platform](https://img.shields.io/badge/platform-%20arm64%20|%20x64%20-teal?style=flat-square)](https://github.com/leukipp/touchkio/releases)
[![downloads](https://img.shields.io/github/downloads/leukipp/touchkio/total?style=flat-square)](https://github.com/leukipp/touchkio/releases)
[![sponsor](https://img.shields.io/github/sponsors/leukipp?color=red&logo=github&style=flat-square)](https://github.com/sponsors/leukipp)
**TouchKio** is a Node.js application that utilizes Electron to create a kiosk mode window specifically designed for a Home Assistant dashboard. **TouchKio** is a Node.js application that utilizes Electron to create a kiosk mode window specifically designed for a Home Assistant dashboard.
This tool is packaged as a **.deb** file, making it easy to launch the kiosk application on any Debian based Linux [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) (e.g. **Raspberry Pi**) equipped with a **DSI or HDMI** Touch Display. This tool is packaged as a **.deb** file, making it easy to launch the kiosk application on any Debian based Linux [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) (e.g. **Raspberry Pi**) equipped with a **DSI or HDMI** Touch Display.
Additional releases for other Linux systems are available as **.zip** file. Additional releases for other Linux systems are available as **.zip** file.
@@ -12,18 +18,22 @@ Moreover, the device running the **kiosk application** also offers several **Hom
- [x] Fast and easy setup. - [x] Fast and easy setup.
- [x] Remember login credentials. - [x] Remember login credentials.
- [x] Touch optimized web browsing. - [x] Touch optimized web browsing.
- [x] Dynamic window status bar layout.
- [x] Side panel widget for kiosk control. - [x] Side panel widget for kiosk control.
- [x] Navigation bar to switch between pages. - [x] Navigation bar to switch between pages.
- [x] Configurable browser zoom and theme support. - [x] Adjustable browser zoom and theme support.
- [x] Extended touch screen wake-up functionality. - [x] Extended touch screen wake-up functionality.
- [x] Remote controllable via MQTT. - [x] Remote controllable via MQTT.
- [x] Manage kiosk window status.
- [x] Toggle the on-screen keyboard. - [x] Toggle the on-screen keyboard.
- [x] Touch display power and brightness. - [x] Touch display power and brightness.
- [x] Manage kiosk window status and zoom.
- [x] Screenshot image of the kiosk webview.
- [x] Show network interfaces and addresses.
- [x] List all available system package upgrades.
- [x] Multi-webpage switching and url navigation. - [x] Multi-webpage switching and url navigation.
- [x] Monitor temperature, processor and memory usage. - [x] Volume control for connected audio outputs.
- [x] Execute system reboot and shutdown commands. - [x] Execute system reboot and shutdown commands.
- [x] Show available system package upgrades. - [x] Monitor battery, temperature, processor and memory usage.
The kiosk application can be executed with command line arguments to load a **Home Assistant dashboard in fullscreen** mode. The kiosk application can be executed with command line arguments to load a **Home Assistant dashboard in fullscreen** mode.
Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Linux device and the connected Touch Display. Additionally, a **MQTT endpoint** can be defined, allowing the application to provide controls and sensors for the Linux device and the connected Touch Display.
@@ -42,11 +52,12 @@ This setup allows seamless communication between your kiosk device and Home Assi
For a comprehensive guide on setting up MQTT with Home Assistant, please refer to the official documentation available here: https://www.home-assistant.io/integrations/mqtt. For a comprehensive guide on setting up MQTT with Home Assistant, please refer to the official documentation available here: https://www.home-assistant.io/integrations/mqtt.
## Installation ## Installation
On the first run of the application, you may encounter a **setup procedure** and the Home Assistant **login screen**. On the first run of the application, you will encounter a **setup procedure (CLI)** and the Home Assistant **login screen (UI)**.
It's recommended to create a dedicated Home Assistant user (local access only) for your kiosk device. It's recommended to create a dedicated Home Assistant user (local access only) for your kiosk device.
You might also need a physical keyboard or remote VNC access to input these credentials once. You might also need a physical keyboard or remote VNC access to input these credentials once.
If your hardware is [supported](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) you may be able to activate the on-screen keyboard using the side [widget](https://github.com/leukipp/touchkio/issues/16). If your hardware is [supported](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) you may be able to activate the on-screen keyboard using the side [widget](https://github.com/leukipp/touchkio/issues/16).
After the first login the Home Assistant credentials should be stored inside the `~/.config/touchkio/Cache` folder.
#### Option 1 - The easy way #### Option 1 - The easy way
Run this command to download and install the latest **.deb** (arm64 or x64) release. Run this command to download and install the latest **.deb** (arm64 or x64) release.
@@ -69,9 +80,8 @@ systemctl --user start|stop|status|restart touchkio.service
When connected via SSH, it's necessary to export the display variables first, as outlined in the [development](https://github.com/leukipp/touchkio?tab=readme-ov-file#development) section. When connected via SSH, it's necessary to export the display variables first, as outlined in the [development](https://github.com/leukipp/touchkio?tab=readme-ov-file#development) section.
The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script mentioned above performs the following tasks (and you just have to do it manually): The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script mentioned above performs the following tasks (and you just have to do it manually):
- [Download](https://github.com/leukipp/touchkio/releases/latest) the latest version file that is suitable for your architecture (arm64 or x64). - [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: - Debian (**deb**): Open a terminal and execute the following command to install the application, e.g: `sudo apt install ./touchkio_1.x.x_arm64.deb && touchkio --setup`
`sudo 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`
- Others (**zip**): Extract the archive and run the binary, e.g: `unzip touchkio-linux-x64-1.x.x.zip && cd touchkio-linux-x64 && ./touchkio --setup`
- If you just want to load a Home Assistant dashboard without further control you are good to go, e.g: `touchkio --web-url=https://demo.home-assistant.io` - If you just want to load a Home Assistant dashboard without further control you are good to go, e.g: `touchkio --web-url=https://demo.home-assistant.io`
- The `--web-url` doesn't necessarily need to be a Home Assistant url, any kind of website can be shown in kiosk mode. - The `--web-url` doesn't necessarily need to be a Home Assistant url, any kind of website can be shown in kiosk mode.
- Only when using the MQTT integration via `--mqtt-*`, a running Home Assistant instance is required. - Only when using the MQTT integration via `--mqtt-*`, a running Home Assistant instance is required.
@@ -91,7 +101,8 @@ yarn build
#### Update #### Update
If you have already installed TouchKio and want to upgrade to the latest version, simply install the newer version over the existing one. If you have already installed TouchKio and want to upgrade to the latest version, simply install the newer version over the existing one.
The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script can also be run to update an existing installation to the **latest version**. The setup procedure can be skipped to use the existing default arguments from the configuration file: The [install.sh](https://github.com/leukipp/touchkio/blob/main/install.sh) script can also be run to update an existing installation to the **latest version**.
The setup procedure can be skipped to use the existing default arguments from the configuration file:
```bash ```bash
bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh) update bash <(wget -qO- https://raw.githubusercontent.com/leukipp/touchkio/main/install.sh) update
``` ```
@@ -223,19 +234,21 @@ While using external sensors that directly integrate with Home Assistant and by
<details><summary>Don't waste your time reading this.</summary><div></br> <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 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 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 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 Raspberry Pi's **build-in screen blanking** function uses the command `swayidle -w timeout 600 'wlopm --off \*' resume 'wlopm --on \*' &` inside `~/.config/labwc/autostart` to blank the screen after **10 minutes**.
The `wlopm --off \*` command changes the `bl_power` value to **4**, when setting the value to **0** the screen will turn on again. The `wlopm --off \*` command changes the `/sys/class/backlight/*/bl_power` value to **4**, when setting the value to **0** the screen will turn on again.
However, `swayidle` still seems to consider the screen to be off and as a result it will not turn off again unless there is some interaction in the meantime. However, `swayidle` still seems to consider the screen to be off and as a result it will not turn off again unless there is some interaction in the meantime.
When using the MQTT integration, the kiosk application must be able to **detect changes** made on the **device** itself. When using the MQTT integration, the kiosk application must be able to **detect changes** made on the **device** itself.
I managed to achieve this for the `brightness` file by implementing a simple `fs.watch(..)` file listener. I managed to achieve this for the `/sys/class/backlight/*/brightness` file by implementing a simple `fs.watch(..)` file listener.
However, I found that it **never triggered** for the `bl_power` file. However, I found that it **never triggered** for the `/sys/class/backlight/*/bl_power` or the `/sys/class/drm/*/dpms` file.
Although the file content changes, none of the filesystem listeners where fired. Although the file content changes, none of the filesystem listeners where fired.
This could be due to `swayidle`/`wlopm` performing write actions at a deeper level that are not detectable by file listeners. This could be due to `swayidle`/`wlopm` performing write actions at a deeper level that are not detectable by file listeners.
As a result, I went for a **polling solution**, checking the state of the file **every second** for any changes.
As a result, I went for a **polling solution**, checking the state of the `/sys/class/backlight/*/brightness` and `/sys/class/drm/*/dpms` file **every second** for any changes.
In case `ddcutil` is installed the file `~/.config/touchkio/Cache/Brightness.vcp` will be written/monitored to support brightness support for HDMI screens.
While I understand this is not ideal, it's necessary to ensure proper functionality. While I understand this is not ideal, it's necessary to ensure proper functionality.
The display power status and brightness can be adjusted via the MQTT integration. The display power status and brightness can be adjusted via the MQTT integration.
@@ -249,9 +262,10 @@ Implementing stronger **security measures** would complicate the setup process a
When using the kiosk application without initializing the default arguments, you will need to provide them with every command. When using the kiosk application without initializing the default arguments, you will need to provide them with every command.
This means that the password may be stored as plain text in various files, such as `touchkio.service`, `~/.bash_history`, etc. This means that the password may be stored as plain text in various files, such as `touchkio.service`, `~/.bash_history`, etc.
To resolve the issue where the first **touch** on a **turned-off screen** triggers a **click event** (which could inadvertently activate Home Assistant actions), a workaround needed to be implemented. To address the problem where the first **touch** on a **turned-off screen** may trigger a **click event** (which could inadvertently activate Home Assistant actions), a workaround needed to be implemented.
When the screen **turns off** the **focus** is removed from the kiosk window. When the screen **turns on/off**, the timestamps of these events are recorded.
This way, the first click only turns the screen on and focuses the window, allowing subsequent clicks to work as expected. If a touch event is detected and the **timestamp** of the **last screen-off** is more recent than the **last screen-on** timestamp, the touch event is discarded and the screen is turned on instead.
From that point onward, subsequent touch interactions will function normally as expected.
Additionally, to address the problem that scrolling only works with the **web browser scrollbar** on the right, the application is configured to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`. Additionally, to address the problem that scrolling only works with the **web browser scrollbar** on the right, the application is configured to **simulate a touch device** using `Emulation.setEmitTouchEventsForMouse`.
This adjustment provides a user experience similar to that of a proper mobile device. This adjustment provides a user experience similar to that of a proper mobile device.
@@ -259,36 +273,26 @@ This adjustment provides a user experience similar to that of a proper mobile de
</div></details> </div></details>
## Issues ## Issues
Please have a look into the [hardware](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md) documentation if you ran into any issues. ### Please review the hardware [FAQ](https://github.com/leukipp/touchkio/blob/main/HARDWARE.md#faq) section first if you encounter any issues.
**Minimal features** are designed to run on any system without issues: For basic debugging **(TouchKio)**, stop the service and launch `touchkio` directly on the terminal to monitor the log output in real-time.
- A webview kiosk window launched in fullscreen mode and loading the specified `--web-url` website should not cause any problems. This output is also written into `~/.config/touchkio/logs/main.log` for review.
**Extended features** become available when the `--mqtt-*` arguments are provided and the hardware is supported: For extended logging **(Electron)** you can run `touchkio --enable-logging`, which will write an additional log file into `~/.config/touchkio/logs/electron.log`.
- If your hardware is not fully compatible there should be no crashes, but you may miss some sensors. Refer to the [--log-level=[0-3]](https://www.electronjs.org/docs/latest/api/command-line-switches#--log-leveln) and [--v=[0-3]](https://www.electronjs.org/docs/latest/api/command-line-switches#--vlog_level) options to adjust the logging verbosity.
- On some Debian based systems (e.g. Ubuntu GNOME), the display status control may only be available when using X11.
- The following commands are currently implemented to modify the display status.
- `wlopm --[on]/[off] *` (Raspberry Pi OS, Wayland)
- `kscreen-doctor --dpms [on]/[off]` (Debian KDE, Wayland)
- `xset dpms force [on]/[off]` (Generic, X11)
**Known Issues** that are by-design or for which there isn't a solution so far: If you need to debug the webview **(Chrome DevTools)** use `touchkio --app-debug`.
- You can use Raspberry Pi's build-in [screen blanking](https://www.raspberrypi.com/documentation/computers/configuration.html#screen-blanking-3) functionality, however, if the screen is turned on through Home Assistant after being automatically turned off, it will remain on indefinitely.
- It's recommended to either use the built-in screen blanking feature or implement a Home Assistant automation (e.g. presence detection) to manage the screen status.
- When using a Raspberry Pi, the on-screen keyboard doesn't [automatically pop-out](https://github.com/leukipp/touchkio/issues/4) when entering a text field inside the webview.
- As a current workaround you can use the side [widget](https://github.com/leukipp/touchkio/issues/16) to toggle the keyboard visibility.
- Certain commands from the MQTT integration may require elevated privileges to work correctly.
- Ensure that your local user has the [necessary permissions](https://github.com/leukipp/touchkio/issues/39) to run `sudo` without being prompted for a password.
- On the terminal you may see some *ERROR:gbm_wrapper.cc* messages.
- This appears to be a [known issue](https://github.com/electron/electron/issues/42322) that currently lacks a fix, but the webview still works.
For debugging, stop the service and run `touchkio` directly on the terminal to view the log outputs. In case of undocumented problems, please [create an issue](https://github.com/leukipp/touchkio/issues) and include the output of `touchkio --version`, additional information about your system (such as operating system, hardware, etc.), and any relevant log files.
If you encounter any problems, please [create an issue](https://github.com/leukipp/touchkio/issues) and include the output of `touchkio --version` along with your system setup and any additional terminal logs.
## Credits ## Credits
[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)).
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. 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)). If you are looking for hardware or a well-designed mounting solution for the Raspberry Pi Display, check out the [blog post](https://www.thestockpot.net/videos/home-assistant-wall-display) from Dillan Stock ([@TheStockPot-AU](https://www.youtube.com/channel/UCmlzNHg8QiWVutUyFOV2UdQ)).
[![What My Smart Home Was Missing](https://img.youtube.com/vi/uTxURzmrVtA/maxresdefault.jpg)](https://www.youtube.com/watch?v=uTxURzmrVtA)
## License ## License
[MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE) [MIT](https://github.com/leukipp/touchkio/blob/main/LICENSE)
+4 -4
View File
@@ -62,13 +62,13 @@ module.exports = {
for (const result of results) { for (const result of results) {
const artifacts = []; const artifacts = [];
for (const artifact of result.artifacts) { for (const artifact of result.artifacts) {
// Currently disabled for zip files as it seems to corrupt them if (artifact.includes(".zip")) {
if (artifact.includes(".zip") && false) {
const [name, platform, arch] = path.basename(artifact).split("-"); const [name, platform, arch] = path.basename(artifact).split("-");
const buildFile = path.join(`${name}-${platform}-${arch}`, "resources", "app", "build.json"); const buildFile = path.join(`${name}-${platform}-${arch}`, "resources", "app", "build.json");
const zip = await jszip.loadAsync(fs.readFileSync(artifact, "utf8")); const options = { type: "nodebuffer", compression: "DEFLATE", compressionOptions: { level: 9 } };
const zip = await jszip.loadAsync(fs.readFileSync(artifact));
zip.file(buildFile, generateBuildFile(platform, arch, "zip")); zip.file(buildFile, generateBuildFile(platform, arch, "zip"));
fs.writeFileSync(artifact, await zip.generateAsync({ type: "nodebuffer" })); fs.writeFileSync(artifact, await zip.generateAsync(options));
} }
if (artifact.includes("amd64")) { if (artifact.includes("amd64")) {
const renamed = artifact.replace("amd64", "x64"); const renamed = artifact.replace("amd64", "x64");
+18 -8
View File
@@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline'" />
<style> <style>
[data-theme="dark"] { [data-theme="dark"] {
--primary-color: rgba(225, 225, 225, 1); --primary-color: rgba(225, 225, 225, 1);
@@ -32,8 +33,8 @@
align-items: center; align-items: center;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
padding: 0 30px; padding: 0 10px;
column-gap: 20px; column-gap: 10px;
list-style-type: none; list-style-type: none;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
background: var(--background-color); background: var(--background-color);
@@ -74,6 +75,7 @@
padding: 6px 10px; padding: 6px 10px;
border-radius: 4px; border-radius: 4px;
font-size: 18px; font-size: 18px;
font-family: sans-serif;
outline: none; outline: none;
border: none; border: none;
cursor: text; cursor: text;
@@ -132,7 +134,7 @@
</li> </li>
<li class="separator"></li> <li class="separator"></li>
<li class="dynamic"> <li class="dynamic">
<input id="url" type="text" spellcheck="false" /> <input id="url" type="text" spellcheck="false" readonly="true" />
</li> </li>
<li class="separator"></li> <li class="separator"></li>
<li> <li>
@@ -187,6 +189,9 @@
}); });
}); });
document.querySelectorAll("input").forEach((input) => { document.querySelectorAll("input").forEach((input) => {
input.addEventListener("blur", (e) => {
ipcRenderer.send("input-blur", { id: e.target.id });
});
input.addEventListener("focus", (e) => { input.addEventListener("focus", (e) => {
ipcRenderer.send("input-focus", { id: e.target.id }); ipcRenderer.send("input-focus", { id: e.target.id });
}); });
@@ -205,16 +210,21 @@
ipcRenderer.on("button-hidden", (e, button) => { ipcRenderer.on("button-hidden", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("hidden", button.hidden); document.getElementById(button.id).parentNode.classList.toggle("hidden", button.hidden);
}); });
ipcRenderer.on("input-blur", (e, input) => { ipcRenderer.on("input-readonly", (e, input) => {
document.getElementById(input.id).blur(); document.getElementById(input.id).readOnly = input.readonly;
});
ipcRenderer.on("input-select", (e, input) => {
document.getElementById(input.id).select();
}); });
ipcRenderer.on("input-text", (e, input) => { ipcRenderer.on("input-text", (e, input) => {
document.getElementById(input.id).value = input.text; document.getElementById(input.id).value = input.text;
document.getElementById(input.id).placeholder = input.placeholder; document.getElementById(input.id).placeholder = input.placeholder;
}); });
ipcRenderer.on("input-select", (e, input) => {
window.getSelection()?.removeAllRanges();
if (input.select) {
document.getElementById(input.id).select();
} else {
document.getElementById(input.id).blur();
}
});
ipcRenderer.on("data-theme", (e, data) => { ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme); document.documentElement.setAttribute("data-theme", data.theme);
}); });
+29 -8
View File
@@ -1,11 +1,22 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline'" />
<style> <style>
[data-theme="dark"] {
--background-color: rgba(225, 225, 225, 0.15);
--previous-cursor: w-resize;
--next-cursor: e-resize;
}
[data-theme="light"] {
--background-color: rgba(33, 33, 33, 0.15);
--previous-cursor: w-resize;
--next-cursor: e-resize;
}
[data-theme="hidden"] { [data-theme="hidden"] {
--primary-color: rgba(0, 0, 0, 0);
--border-color: rgba(0, 0, 0, 0);
--background-color: rgba(0, 0, 0, 0); --background-color: rgba(0, 0, 0, 0);
--previous-cursor: none;
--next-cursor: none;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
display: none; display: none;
@@ -28,24 +39,31 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
list-style-type: none; list-style-type: none;
background: var(--background-color);
} }
li { li {
display: flex; display: flex;
align-items: center; align-items: center;
flex: 0 0 auto; flex: 0 0 auto;
} }
li:not(.disabled) #previous {
cursor: var(--previous-cursor);
}
li:not(.disabled) #next {
cursor: var(--next-cursor);
}
button { button {
width: 100vw; width: 100vw;
height: 50vh; height: 50vh;
border: none; border: none;
cursor: not-allowed;
background-color: transparent; background-color: transparent;
} }
#previous { button:not(:active) {
cursor: w-resize; transition: background-color 500ms step-end;
} }
#next { menu:has(button:active) button {
cursor: e-resize; background-color: var(--background-color);
transition-duration: 0ms;
} }
</style> </style>
</head> </head>
@@ -63,13 +81,16 @@
const registerSendEvents = () => { const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => { document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => { button.addEventListener("pointerdown", (e) => {
ipcRenderer.send("button-click", { id: e.target.id }); ipcRenderer.send("button-click", { id: e.target.id });
}); });
}); });
}; };
const registerReceiveEvents = () => { const registerReceiveEvents = () => {
ipcRenderer.on("button-disabled", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("disabled", button.disabled);
});
ipcRenderer.on("data-theme", (e, data) => { ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme); document.documentElement.setAttribute("data-theme", data.theme);
}); });
+162
View File
@@ -0,0 +1,162 @@
<!doctype html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline'" />
<style>
[data-theme="dark"] {
--primary-color: rgba(229, 229, 229, 1);
--background-color: rgba(66, 67, 68, 1);
--minimize-color: rgba(52, 199, 89, 1);
--fullscreen-color: rgba(255, 149, 0, 1);
--terminate-color: rgba(255, 59, 48, 1);
}
[data-theme="light"] {
--primary-color: rgba(26, 26, 26, 1);
--background-color: rgba(237, 236, 235, 1);
--minimize-color: rgba(52, 199, 89, 1);
--fullscreen-color: rgba(255, 149, 0, 1);
--terminate-color: rgba(255, 59, 48, 1);
}
::-webkit-scrollbar {
display: none;
}
* {
margin: 0;
padding: 0;
cursor: pointer;
}
body {
display: flex;
align-items: center;
width: 100vw;
height: 100vh;
background: var(--background-color);
}
header {
display: flex;
align-items: center;
flex: 1 1 0%;
height: 100vh;
-webkit-app-region: drag;
}
h1 {
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
font-size: 18px;
font-weight: 500;
font-family: sans-serif;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
text-align: center;
user-select: none;
color: var(--primary-color);
}
menu {
display: flex;
flex-direction: row;
align-items: center;
flex: 0 0 auto;
height: 100vh;
min-width: 116px;
padding: 0px 10px;
column-gap: 10px;
list-style-type: none;
background: var(--background-color);
position: relative;
z-index: 1;
}
li {
display: flex;
align-items: center;
flex: 0 0 auto;
}
li.disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
button {
width: 32px;
height: 32px;
border: none;
background-color: transparent;
}
svg {
pointer-events: none;
}
#minimize > svg {
fill: var(--minimize-color);
}
#fullscreen > svg {
fill: var(--fullscreen-color);
}
#terminate > svg {
fill: var(--terminate-color);
}
</style>
</head>
<body>
<header>
<h1 id="title">TouchKio</h1>
</header>
<menu>
<li>
<button id="minimize">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>minus-circle</title>
<path d="M17,13H7V11H17M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" />
</svg>
</button>
</li>
<li>
<button id="fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>chevron-up-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,12M7.4,15.4L12,10.8L16.6,15.4L18,14L12,8L6,14L7.4,15.4Z"
/>
</svg>
</button>
</li>
<li>
<button id="terminate">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>close-circle</title>
<path
d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"
/>
</svg>
</button>
</li>
</menu>
<script>
const { ipcRenderer } = require("electron");
const registerSendEvents = () => {
document.querySelectorAll("button").forEach((button) => {
button.addEventListener("click", (e) => {
ipcRenderer.send("button-click", { id: e.target.id });
});
});
};
const registerReceiveEvents = () => {
ipcRenderer.on("button-disabled", (e, button) => {
document.getElementById(button.id).parentNode.classList.toggle("disabled", button.disabled);
});
ipcRenderer.on("text-content", (e, text) => {
document.getElementById(text.id).textContent = text.content;
});
ipcRenderer.on("data-theme", (e, data) => {
document.documentElement.setAttribute("data-theme", data.theme);
});
};
registerSendEvents();
registerReceiveEvents();
</script>
</body>
</html>
+14 -13
View File
@@ -1,6 +1,7 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta http-equiv="Content-Security-Policy" content="default-src 'unsafe-inline'" />
<style> <style>
[data-theme="dark"] { [data-theme="dark"] {
--primary-color: rgba(225, 225, 225, 1); --primary-color: rgba(225, 225, 225, 1);
@@ -68,16 +69,6 @@
</head> </head>
<body> <body>
<menu> <menu>
<li>
<button id="keyboard">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>keyboard-close-outline</title>
<path
d="M12 23L16 19H8M4 3C2.9 3 2 3.9 2 5V15C2 16.1 2.9 17 4 17H20C21.1 17 22 16.1 22 15V5C22 3.9 21.1 3 20 3H4M4 5H20V15H4V5M5 6V8H7V6H5M8 6V8H10V6H8M11 6V8H13V6H11M14 6V8H16V6H14M17 6V8H19V6H17M5 9V11H7V9H5M8 9V11H10V9H8M11 9V11H13V9H11M14 9V11H16V9H14M17 9V11H19V9H17M8 12V14H16V12H8Z"
/>
</svg>
</button>
</li>
<li> <li>
<button id="fullscreen"> <button id="fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
@@ -89,11 +80,21 @@
</button> </button>
</li> </li>
<li> <li>
<button id="minimize"> <button id="theme">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>image-size-select-small</title> <title>compare</title>
<path <path
d="M23,15H21V17H23V15M23,11H21V13H23V11M23,19H21V21C22,21 23,20 23,19M15,3H13V5H15V3M23,7H21V9H23V7M21,3V5H23C23,4 22,3 21,3M3,21H11V15H1V19A2,2 0 0,0 3,21M3,7H1V9H3V7M15,19H13V21H15V19M19,3H17V5H19V3M19,19H17V21H19V19M3,3C2,3 1,4 1,5H3V3M3,11H1V13H3V11M11,3H9V5H11V3M7,3H5V5H7V3Z" d="M19,3H14V5H19V18L14,12V21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10,18H5L10,12M10,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H10V23H12V1H10V3Z"
/>
</svg>
</button>
</li>
<li>
<button id="keyboard">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<title>keyboard-close-outline</title>
<path
d="M12 23L16 19H8M4 3C2.9 3 2 3.9 2 5V15C2 16.1 2.9 17 4 17H20C21.1 17 22 16.1 22 15V5C22 3.9 21.1 3 20 3H4M4 5H20V15H4V5M5 6V8H7V6H5M8 6V8H10V6H8M11 6V8H13V6H11M14 6V8H16V6H14M17 6V8H19V6H17M5 9V11H7V9H5M8 9V11H10V9H8M11 9V11H13V9H11M14 9V11H16V9H14M17 9V11H19V9H17M8 12V14H16V12H8Z"
/> />
</svg> </svg>
</button> </button>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 152 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 90 KiB

+151 -13
View File
@@ -5,36 +5,64 @@ const readline = require("readline/promises");
const integration = require("./js/integration"); const integration = require("./js/integration");
const hardware = require("./js/hardware"); const hardware = require("./js/hardware");
const webview = require("./js/webview"); const webview = require("./js/webview");
const { app } = require("electron"); const log = require("electron-log");
const { app, powerMonitor } = require("electron");
const Events = require("events");
global.APP = global.APP || {}; global.APP = global.APP || {};
global.ARGS = global.ARGS || {}; global.ARGS = global.ARGS || {};
global.EVENTS = global.EVENTS || new Events();
// Check display environment variable
if (!process.env.DISPLAY) { if (!process.env.DISPLAY) {
console.error(`\n$DISPLAY variable not set to run the GUI application, are you connected via SSH?\n`); 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(`If you have installed the service use:`);
console.error(` systemctl --user start touchkio.service`); console.error(` systemctl --user start touchkio.service`);
console.error(`Alternatively export the variables first:`); console.error(`Alternatively export the variables first:`);
console.error(` export DISPLAY=":0" && export WAYLAND_DISPLAY="wayland-0" && touchkio\n`); console.error(` export DISPLAY=":0" && export WAYLAND_DISPLAY="wayland-0" && touchkio\n`);
process.exit(1);
} }
// Delete electron log file
const elog = path.join(app.getPath("logs"), "electron.log");
try {
if (fs.existsSync(elog)) {
fs.unlinkSync(elog);
}
console.debug = () => {};
} catch (error) {
console.error("Failed to delete electron log file:", error.message);
}
app.commandLine.appendSwitch("log-file", elog);
/** /**
* This promise resolves when the app has finished initializing, * This promise resolves when the app has finished initializing,
* allowing to safely create browser windows and perform other * allowing to safely create browser windows and perform other
* initialization tasks. * initialization tasks.
*/ */
app.whenReady().then(async () => { app.whenReady().then(async () => {
if (!(await initApp()) || !(await initArgs())) { if (!(await initApp()) || !(await initArgs()) || !(await initLog())) {
return; return;
} }
// Show used arguments // Show used arguments
console.log(`Arguments: ${JSON.stringify(ARGS, null, 2)}`); const args = Object.assign({}, ARGS);
if ("mqtt_password" in args) {
args.mqtt_password = "*".repeat((args.mqtt_password || "").length);
}
console.info(`Arguments: ${JSON.stringify(args, null, 2)}`);
process.stdout.write("\n");
// Chained init functions // Chained init functions
const chained = [webview.init, hardware.init, integration.init]; const chained = [
for (const init of chained) { ["webview.js", webview.init],
["hardware.js", hardware.init],
["integration.js", integration.init],
];
for (const [name, init] of chained) {
console.debug(`${name}: init()`);
if (!(await init())) { if (!(await init())) {
console.debug(`${name}: init() --> aborted`);
break; break;
} }
} }
@@ -57,6 +85,9 @@ const initApp = async () => {
APP.title = packageJson.title; APP.title = packageJson.title;
APP.version = app.getVersion(); APP.version = app.getVersion();
APP.path = app.getAppPath(); APP.path = app.getAppPath();
APP.icon = path.join(app.getAppPath(), "img", "icon.png");
APP.log = path.join(app.getPath("logs"), "main.log");
APP.cache = path.join(app.getPath("userData"), "Cache");
APP.config = app.getPath("userData"); APP.config = app.getPath("userData");
// Set additional update infos // Set additional update infos
@@ -65,6 +96,7 @@ const initApp = async () => {
url: `https://api.github.com/repos/${packageJson.author}/${packageJson.name}/releases`, url: `https://api.github.com/repos/${packageJson.author}/${packageJson.name}/releases`,
latest: null, latest: null,
}; };
APP.issues = `https://github.com/${packageJson.author}/${packageJson.name}/issues`;
APP.scripts = { APP.scripts = {
install: `https://raw.githubusercontent.com/${packageJson.author}/${packageJson.name}/main/install.sh`, install: `https://raw.githubusercontent.com/${packageJson.author}/${packageJson.name}/main/install.sh`,
}; };
@@ -78,9 +110,35 @@ const initApp = async () => {
// Request single app instance lock // Request single app instance lock
if (!app.requestSingleInstanceLock()) { if (!app.requestSingleInstanceLock()) {
console.error(`${APP.title} is already running`); console.error(`${APP.title} is already running`);
return app.quit(); return app.exit(1);
} }
// Register app quit events
app.on("before-quit", () => {
APP.exiting = true;
});
app.on("will-quit", (e) => {
e.preventDefault();
process.exitCode = process.exitCode !== 0 ? 1 : 0;
const level = process.exitCode === 0 ? "warn" : "error";
console[level](`${APP.title} Terminated (${process.exitCode})`);
app.exit(process.exitCode);
});
// Register process exit events
["SIGINT", "SIGTERM", "SIGQUIT", "SIGTRAP", "exit"].forEach((signal) => {
process.on(signal, () => {
process.exitCode = 0;
APP.exiting = true;
app.quit();
});
});
powerMonitor.on("shutdown", () => {
process.exitCode = 0;
APP.exiting = true;
app.quit();
});
return true; return true;
}; };
@@ -96,31 +154,34 @@ const initArgs = async () => {
let argsFilePath = path.join(APP.config, "Arguments.json"); let argsFilePath = path.join(APP.config, "Arguments.json");
let argsFileExists = fs.existsSync(argsFilePath); let argsFileExists = fs.existsSync(argsFilePath);
let argsFileHashPath = path.join(APP.cache, "Arguments.hash");
let argsFileHashExists = fs.existsSync(argsFileHashPath);
// Show version and release info // Show version and release info
if ("help" in args || "version" in args) { if ("help" in args || "version" in args) {
let build = ""; let build = "";
if (APP.build.id) { if (APP.build.id) {
build = ` (${APP.build.id}), built on ${APP.build.date} (${APP.build.platform}-${APP.build.arch}-${APP.build.maker})`; build = ` (${APP.build.id}), built on ${APP.build.date} (${APP.build.platform}-${APP.build.arch}-${APP.build.maker})`;
} }
console.log(`${APP.name}-v${APP.version}${build}\n${APP.homepage}`); console.info(`${APP.name}-v${APP.version}${build}\n${APP.homepage}`);
return app.quit(); return app.exit(0);
} }
// Setup arguments from file path // Setup arguments from file path
if ("setup" in args || (!argsProvided && !argsFileExists)) { if ((!argsProvided && !argsFileExists) || "setup" in args) {
await sleep(3000); await sleep(3000);
do { do {
args = await promptArgs(process); args = await promptArgs(process);
} while (!Object.keys(args).length); } while (!Object.keys(args).length);
writeArgs(argsFilePath, args); writeArgs(argsFilePath, args);
} else if (!argsProvided && argsFileExists) { } else if (argsFileExists) {
args = readArgs(argsFilePath); args = { ...readArgs(argsFilePath), ...args };
} }
// Check arguments object // Check arguments object
if (!Object.keys(args).length) { if (!Object.keys(args).length) {
console.error(`No arguments provided`); console.error(`No arguments provided`);
return app.quit(); return app.exit(1);
} }
// Split url arguments // Split url arguments
@@ -128,11 +189,88 @@ const initArgs = async () => {
if (!Array.isArray(args.web_url)) { if (!Array.isArray(args.web_url)) {
args.web_url = args.web_url.split(",").map((url) => url.trim()); args.web_url = args.web_url.split(",").map((url) => url.trim());
} }
// Calculate arguments hash
const argsFileHash = crypto.createHash("sha256").update(JSON.stringify(args)).digest("hex");
const argsUpdated = argsFileHashExists && argsFileHash !== fs.readFileSync(argsFileHashPath, "utf8");
if (argsUpdated && !("app_reset" in args)) {
args.app_reset = "arguments";
}
fs.writeFileSync(argsFileHashPath, argsFileHash);
// Set global arguments
ARGS = args; ARGS = args;
return true; return true;
}; };
/**
* Initializes the global log object.
*
* @returns {bool} Returns true if the initialization was successful.
*/
const initLog = async () => {
try {
if (fs.existsSync(APP.log)) {
fs.unlinkSync(APP.log);
}
} catch (error) {
console.error("Failed to delete main log file:", error.message);
}
// Set log level and path
const level = "enable_logging" in ARGS ? "silly" : "app_debug" in ARGS ? "debug" : "verbose";
log.transports.file.level = level;
log.transports.console.level = level;
log.transports.file.resolvePathFn = () => {
return APP.log;
};
// Catch unhandled errors
log.errorHandler.startCatching({
showDialog: false,
onError({ error, versions }) {
if (!error?.message?.includes("Object has been destroyed")) {
const build = { ...APP.build, ...versions };
const whoopsie = "💥 Whoopsie!";
const section2 = `# Description\n- Hardware information?\n- How to reproduce?\n- Additional logs?\n`;
const section3 = `# Error\n\`\`\`bash\n${new Date().toISOString()}: ${error.stack}\n\`\`\`\n`;
const section4 = `# Application\n\`\`\`json\n${JSON.stringify(build, null, 2)}\n\`\`\`\n`;
const title = encodeURIComponent(`${whoopsie} - ${error}`);
const body = encodeURIComponent(`${section2}\n${section3}\n${section4}`);
console.error(`${whoopsie} -`, error, build);
console.info(`🪲 Report issue --> ${APP.issues}/new?title=${title}&body=${body}`);
}
app.quit();
},
});
// Emit console log
APP.logs = [];
log.hooks.push((message, transport, type) => {
const data = message.data.map((d) => (typeof d === "object" ? String(d?.message) || JSON.stringify(d) : String(d)));
const text = data.filter((s) => s && s.trim()).join(" ");
if (!text.startsWith("(node:") && type === "console") {
APP.logs.unshift({
time: message.date,
level: message.level,
text: text,
});
if (APP.logs.length > 10) {
APP.logs.splice(10);
}
EVENTS.emit("consoleLog", APP.logs[0]);
}
return message;
});
// Overwrite console log
Object.assign(console, log.functions);
console.silly("Welcome To The Jungle!");
return true;
};
/** /**
* Parses command-line arguments from the given process object. * Parses command-line arguments from the given process object.
* *
@@ -249,7 +387,7 @@ const promptArgs = async (proc) => {
} catch (error) { } catch (error) {
console.error(`\n${error.message}`); console.error(`\n${error.message}`);
args = {}; args = {};
app.quit(); app.exit(1);
} finally { } finally {
read.close(); read.close();
} }
+19 -18
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Read arguments # Read arguments
ARG_EARLY=false
ARG_UPDATE=false ARG_UPDATE=false
for arg in "$@"; do for arg in "$@"; do
if [ "$arg" = "update" ]; then case "$arg" in
ARG_UPDATE=true early) ARG_EARLY=true ;;
fi update) ARG_UPDATE=true ;;
esac
done done
# Determine system architecture # Determine system architecture
@@ -13,15 +15,9 @@ echo -e "Determining system architecture..."
BITS=$(getconf LONG_BIT) BITS=$(getconf LONG_BIT)
case "$(uname -m)" in case "$(uname -m)" in
aarch64) x86_64) ARCH="x64" ;;
ARCH="arm64" aarch64) ARCH="arm64" ;;
;; *) { echo "Architecture $(uname -m) running $BITS-bit operating system is not supported."; exit 1; } ;;
x86_64)
ARCH="x64"
;;
*)
{ echo "Architecture $(uname -m) running $BITS-bit operating system is not supported."; exit 1; }
;;
esac esac
[ "$BITS" -eq 64 ] || { echo "Architecture $ARCH running $BITS-bit operating system is not supported."; exit 1; } [ "$BITS" -eq 64 ] || { echo "Architecture $ARCH running $BITS-bit operating system is not supported."; exit 1; }
@@ -31,12 +27,18 @@ echo "Architecture $ARCH running $BITS-bit operating system is supported."
echo -e "\nDownloading the latest release..." echo -e "\nDownloading the latest release..."
TMP_DIR=$(mktemp -d) TMP_DIR=$(mktemp -d)
DEB_URL=$(wget -qO- https://api.github.com/repos/leukipp/touchkio/releases/latest | \
grep -o "\"browser_download_url\": \"[^\"]*_${ARCH}\.deb\"" | \
sed 's/"browser_download_url": "//;s/"//g')
DEB_PATH="${TMP_DIR}/$(basename "$DEB_URL")"
chmod 755 "$TMP_DIR" chmod 755 "$TMP_DIR"
JSON=$(wget -qO- "https://api.github.com/repos/leukipp/touchkio/releases" | tr -d '\r\n')
if $ARG_EARLY; then
DEB_REG='"prerelease":\s*(true|false).*?"browser_download_url":\s*"\K[^\"]*_'$ARCH'\.deb'
else
DEB_REG='"prerelease":\s*false.*?"browser_download_url":\s*"\K[^\"]*_'$ARCH'\.deb'
fi
DEB_URL=$(echo "$JSON" | grep -oP "$DEB_REG" | head -n 1)
DEB_PATH="${TMP_DIR}/$(basename "$DEB_URL")"
[ -z "$DEB_URL" ] && { echo "Download url for .deb file not found."; exit 1; } [ -z "$DEB_URL" ] && { echo "Download url for .deb file not found."; exit 1; }
wget --show-progress -q -O "$DEB_PATH" "$DEB_URL" || { echo "Failed to download the .deb file."; exit 1; } wget --show-progress -q -O "$DEB_PATH" "$DEB_URL" || { echo "Failed to download the .deb file."; exit 1; }
@@ -108,8 +110,7 @@ else
fi fi
# Start the setup mode # Start the setup mode
echo "" read -p $'\nStart touchkio setup? (Y/n) ' setup
read -p "Start touchkio setup? (Y/n) " setup
if [[ ${setup:-y} == [Yy]* ]]; then if [[ ${setup:-y} == [Yy]* ]]; then
echo "/usr/bin/touchkio --setup" echo "/usr/bin/touchkio --setup"
+421 -150
View File
@@ -3,13 +3,11 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const fsp = require("fs/promises"); const fsp = require("fs/promises");
const cpr = require("child_process"); const cpr = require("child_process");
const Events = require("events");
global.HARDWARE = global.HARDWARE || { global.HARDWARE = global.HARDWARE || {
initialized: false, initialized: false,
events: new Events(),
session: {},
support: {}, support: {},
session: {},
battery: { battery: {
level: { level: {
path: null, path: null,
@@ -17,19 +15,22 @@ global.HARDWARE = global.HARDWARE || {
}, },
display: { display: {
status: { status: {
value: null,
path: null, path: null,
command: null, command: null,
value: {},
}, },
brightness: { brightness: {
value: null,
path: null, path: null,
max: null, command: null,
value: {},
}, },
}, },
keyboard: { keyboard: {
visible: null, visible: null,
}, },
audio: {
device: null,
},
}; };
/** /**
@@ -39,7 +40,7 @@ global.HARDWARE = global.HARDWARE || {
*/ */
const init = async () => { const init = async () => {
if (!compatibleSystem()) { if (!compatibleSystem()) {
console.warn("Operating system is not supported\n"); console.error("Operating system is not supported");
return false; return false;
} }
@@ -51,67 +52,107 @@ const init = async () => {
HARDWARE.display.status.path = getDisplayStatusPath(); HARDWARE.display.status.path = getDisplayStatusPath();
HARDWARE.display.status.command = getDisplayStatusCommand(); HARDWARE.display.status.command = getDisplayStatusCommand();
HARDWARE.display.brightness.path = getDisplayBrightnessPath(); HARDWARE.display.brightness.path = getDisplayBrightnessPath();
HARDWARE.display.brightness.max = getDisplayBrightnessMax(); HARDWARE.display.brightness.command = getDisplayBrightnessCommand();
HARDWARE.display.brightness.value.max = getDisplayBrightnessMax();
HARDWARE.audio.device = getAudioDevice();
HARDWARE.support = checkSupport(); HARDWARE.support = checkSupport();
HARDWARE.initialized = true; HARDWARE.initialized = true;
// Show supported features // Show supported features
console.log(`\nSupported: ${JSON.stringify(HARDWARE.support, null, 2)}`); process.stdout.write("\n");
console.info(`Supported: ${JSON.stringify(HARDWARE.support, null, 2)}`);
// Show session infos // Show session infos
console.log("\nUser:", HARDWARE.session.user); process.stdout.write("\n");
console.log("Session:", HARDWARE.session.type); console.info("User:", HARDWARE.session.user);
console.log("Desktop:", HARDWARE.session.desktop); console.info("Session:", HARDWARE.session.type);
console.info("Desktop:", HARDWARE.session.desktop);
// Show device infos // Show device infos
console.log("\nModel:", getModel()); process.stdout.write("\n");
console.log("Vendor:", getVendor()); console.info("Model:", getModel());
console.log("Serial Number:", getSerialNumber()); console.info("Vendor:", getVendor());
console.log("Network Addresses:", getNetworkAddresses()); console.info("Serial Number:", getSerialNumber());
console.log("Host Name:", getHostName()); console.info("Network Addresses:", getNetworkAddresses());
console.info("Host Name:", getHostName());
// Show system infos // Show system infos
console.log("\nUp Time:", getUpTime()); process.stdout.write("\n");
console.log("Memory Size:", getMemorySize()); console.info("Up Time:", getUpTime());
console.log("Memory Usage:", getMemoryUsage()); console.info("Memory Size:", getMemorySize());
console.log("Processor Usage:", getProcessorUsage()); console.info("Memory Usage:", getMemoryUsage());
console.log("Processor Temperature:", getProcessorTemperature()); console.info("Processor Usage:", getProcessorUsage());
console.info("Processor Temperature:", getProcessorTemperature());
// Show hardware infos // Show hardware infos
process.stdout.write("\n");
const unsupported = "unsupported"; const unsupported = "unsupported";
console.log( const batteryLevel = `${getBatteryLevel()} (sysfs)`;
`\nBattery Level [${HARDWARE.battery.level.path || unsupported}]:`, const batteryLevelInfo = HARDWARE.support.batteryLevel ? batteryLevel : unsupported;
`${getBatteryLevel() || unsupported}`, console.info(
`Battery Level [${HARDWARE.support.batteryLevel ? HARDWARE.battery.level.path : unsupported}]:`,
batteryLevelInfo,
); );
console.log( const displayStatus = `${getDisplayStatus()} (${HARDWARE.display.status.command})`;
`Display Status [${HARDWARE.display.status.path || unsupported}]:`, const displayStatusInfo = HARDWARE.support.displayStatus ? displayStatus : unsupported;
`${getDisplayStatus() || unsupported} (${HARDWARE.display.status.command || unsupported})`, console.info(
`Display Status [${HARDWARE.support.displayStatus ? HARDWARE.display.status.path : unsupported}]:`,
displayStatusInfo,
); );
console.log( const displayBrightness = `${getDisplayBrightness()} (${HARDWARE.display.brightness.command || "sysfs"})`;
`Display Brightness [${HARDWARE.display.brightness.path || unsupported}]:`, const displayBrightnessInfo = HARDWARE.support.displayBrightness ? displayBrightness : unsupported;
`${getDisplayBrightness() || unsupported}`, console.info(
`Display Brightness [${HARDWARE.support.displayBrightness ? HARDWARE.display.brightness.path || "ddc://vcp/feature/0x10" : unsupported}]:`,
displayBrightnessInfo,
); );
console.log( const audioVolume = `${getAudioVolume()} (pactl)`;
`Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "squeekboard" : unsupported}]:`, const audioVolumeInfo = HARDWARE.support.audioVolume ? audioVolume : unsupported;
`${getKeyboardVisibility() || unsupported}\n`, console.info(
`Audio Volume [${HARDWARE.support.audioVolume ? HARDWARE.audio.device : unsupported}]:`,
audioVolumeInfo,
); );
const keyboardVisibility = `${getKeyboardVisibility()} (squeekboard)`;
const keyboardVisibilityInfo = HARDWARE.support.keyboardVisibility ? keyboardVisibility : unsupported;
console.info(
`Keyboard Visibility [${HARDWARE.support.keyboardVisibility ? "dbus://sm/puri/OSK0" : unsupported}]:`,
keyboardVisibilityInfo,
);
process.stdout.write("\n");
// Check for keyboard visibility // Monitor audio volume
setKeyboardVisibility("OFF", (reply, error) => { if (HARDWARE.support.audioVolume) {
if (!reply || error) { commandMonitor("pactl", ["subscribe"], (reply, error) => {
return; if (!reply || error) {
}
dbusMonitor("/sm/puri/OSK0", (property, error) => {
if (!property || error) {
return; return;
} }
HARDWARE.keyboard.visibility = property.Visible === "true"; if (reply.includes("'change' on sink")) {
HARDWARE.events.emit("updateKeyboard"); console.info("Update Audio Volume:", getAudioVolume());
EVENTS.emit("updateVolume");
}
}); });
}); }
// Check for display changes // Monitor keyboard visibility
interval(update, 1000); if (HARDWARE.support.keyboardVisibility) {
setKeyboardVisibility("OFF", (reply, error) => {
if (!reply || error) {
return;
}
dbusMonitor("/sm/puri/OSK0", (property, error) => {
if (!property || error) {
return;
}
HARDWARE.keyboard.visibility = property.Visible === "true";
console.info("Update Keyboard Visibility:", getKeyboardVisibility());
EVENTS.emit("updateKeyboard");
});
});
}
// Monitor display changes (1s)
setDisplayStatus("ON", () => {
interval(update, 1 * 1000);
});
return true; return true;
}; };
@@ -120,27 +161,69 @@ const init = async () => {
* Updates the shared hardware properties. * Updates the shared hardware properties.
*/ */
const update = async () => { const update = async () => {
if (!HARDWARE.initialized) { if (!HARDWARE.initialized || APP.exiting) {
return; return;
} }
// Display status has changed // Check if display status has changed
if (HARDWARE.support.displayStatus) { if (HARDWARE.support.displayStatus) {
const status = (await fsp.readFile(`${HARDWARE.display.status.path}/dpms`, "utf8")).trim(); let displayStatusChanged = false;
if (status !== HARDWARE.display.status.value) {
console.log("Update Display Status:", getDisplayStatus()); // Use sysfs dpms path if available
HARDWARE.display.status.value = status; if (HARDWARE.display.status.path) {
HARDWARE.events.emit("updateDisplay"); const power = await readFile(path.join(HARDWARE.display.status.path, "dpms"), false);
const powerChanged = !!power && power !== HARDWARE.display.status.value.power;
// Update internal status values
HARDWARE.display.status.value.power = power;
displayStatusChanged |= powerChanged;
}
// Use sysfs status path if available
if (HARDWARE.display.status.path) {
const connection = await readFile(path.join(HARDWARE.display.status.path, "status"), false);
const connectionChanged = !!connection && connection !== HARDWARE.display.status.value.connection;
// Update internal status values
HARDWARE.display.status.value.connection = connection;
displayStatusChanged |= connectionChanged;
}
// Emit display event if changed
if (displayStatusChanged) {
EVENTS.emit("updateDisplay");
console.info("Update Display Status:", getDisplayStatus());
} }
} }
// Display brightness has changed // Check if display brightness has changed
if (HARDWARE.support.displayBrightness) { if (HARDWARE.support.displayBrightness) {
const brightness = (await fsp.readFile(`${HARDWARE.display.brightness.path}/brightness`, "utf8")).trim(); let displayBrightnessChanged = false;
if (brightness !== HARDWARE.display.brightness.value) {
console.log("Update Display Brightness:", getDisplayBrightness()); // Use cache brightness path if available
HARDWARE.display.brightness.value = brightness; if (HARDWARE.display.brightness.command) {
HARDWARE.events.emit("updateDisplay"); const brightness = await readFile(path.join(APP.cache, "Brightness.vcp"), false);
const brightnessChanged = !!brightness && brightness !== HARDWARE.display.brightness.value.brightness;
// Update internal brightness values
HARDWARE.display.brightness.value.brightness = brightness;
displayBrightnessChanged |= brightnessChanged;
}
// Use sysfs brightness path if available
if (HARDWARE.display.brightness.path) {
const brightness = await readFile(path.join(HARDWARE.display.brightness.path, "brightness"), false);
const brightnessChanged = !!brightness && brightness !== HARDWARE.display.brightness.value.brightness;
// Update internal brightness values
HARDWARE.display.brightness.value.brightness = brightness;
displayBrightnessChanged |= brightnessChanged;
}
// Emit display event if changed
if (displayBrightnessChanged) {
EVENTS.emit("updateDisplay");
console.info("Update Display Brightness:", getDisplayBrightness());
} }
} }
}; };
@@ -203,20 +286,26 @@ const sessionDesktop = () => {
* @returns {Object} Returns support object with boolean values. * @returns {Object} Returns support object with boolean values.
*/ */
const checkSupport = () => { const checkSupport = () => {
const battery = HARDWARE.battery.level;
const status = HARDWARE.display.status;
const brightness = HARDWARE.display.brightness;
const keyboard = processRuns("squeekboard");
const service = serviceRuns(APP.name);
const sudo = sudoRights(); const sudo = sudoRights();
const deb = APP.build.maker === "deb"; const service = serviceRuns(APP.name);
const keyboard = processRuns("squeekboard");
const release = APP.build.maker === "deb";
const audioDevice = !!HARDWARE.audio.device;
const batteryPath = !!HARDWARE.battery.level.path;
const statusPath = !!HARDWARE.display.status.path;
const statusCommand = !!HARDWARE.display.status.command;
const brightnessPath = !!HARDWARE.display.brightness.path && !!HARDWARE.display.brightness.value.max;
const brightnessCommand = !!HARDWARE.display.brightness.command && !!HARDWARE.display.brightness.value.max;
return { return {
batteryLevel: !!battery.path, batteryLevel: batteryPath,
displayStatus: !!status.path && !!status.command, displayStatus: statusPath && statusCommand,
displayBrightness: sudo && !!brightness.path && !!brightness.max, displayBrightness: sudo && statusPath && statusCommand && (brightnessPath || brightnessCommand),
keyboardVisibility: keyboard, keyboardVisibility: keyboard,
audioVolume: audioDevice,
sudoRights: sudo, sudoRights: sudo,
appUpdate: sudo && service && deb, appUpdate: service && sudo && release,
}; };
}; };
@@ -368,9 +457,12 @@ const getProcessorTemperature = () => {
if (!fs.existsSync(typeFile) || !fs.existsSync(tempFile)) { if (!fs.existsSync(typeFile) || !fs.existsSync(tempFile)) {
continue; continue;
} }
const type = fs.readFileSync(typeFile, "utf8").trim(); const type = readFile(typeFile);
if (["cpu-thermal", "x86_pkg_temp", "k10temp", "acpitz"].includes(type)) { if (["cpu-thermal", "x86_pkg_temp", "k10temp", "acpitz", "cpu"].includes(type)) {
return parseFloat(fs.readFileSync(tempFile, "utf8").trim()) / 1000; const temp = readFile(tempFile);
if (temp) {
return parseFloat(temp) / 1000;
}
} }
} }
return null; return null;
@@ -402,7 +494,7 @@ const getBatteryLevel = () => {
if (!HARDWARE.support.batteryLevel) { if (!HARDWARE.support.batteryLevel) {
return null; return null;
} }
const capacity = fs.readFileSync(`${HARDWARE.battery.level.path}/capacity`, "utf8").trim(); const capacity = readFile(`${HARDWARE.battery.level.path}/capacity`);
if (capacity) { if (capacity) {
return parseFloat(capacity); return parseFloat(capacity);
} }
@@ -421,7 +513,7 @@ const getDisplayStatusPath = () => {
if (!fs.existsSync(statusFile)) { if (!fs.existsSync(statusFile)) {
continue; continue;
} }
const content = fs.readFileSync(statusFile, "utf8").trim(); const content = readFile(statusFile);
if (content === "connected") { if (content === "connected") {
return path.join(drm, card); return path.join(drm, card);
} }
@@ -449,7 +541,6 @@ const getDisplayStatusCommand = () => {
return map.command; return map.command;
} }
} }
console.warn("No display command available");
return null; return null;
}; };
@@ -480,7 +571,7 @@ const getDisplayStatus = () => {
case "xset": case "xset":
const xset = execSyncCommand("xset", ["-q"]); const xset = execSyncCommand("xset", ["-q"]);
if (xset !== null) { if (xset !== null) {
const output = /Monitor is On/.test(xset); const output = xset.includes("Monitor is On");
return output ? "ON" : "OFF"; return output ? "ON" : "OFF";
} }
break; break;
@@ -538,23 +629,62 @@ const getDisplayBrightnessPath = () => {
}; };
/** /**
* Gets the maximum display brightness value using `/sys/class/backlight/.../max_brightness`. * Gets the available display brightness command checking for `ddcutil`.
* *
* @returns {number|null} The brightness maximum value or null if an error occurs. * @returns {string|null} The display brightness command or null if nothing was found.
*/ */
const getDisplayBrightnessMax = () => { const getDisplayBrightnessCommand = () => {
if (!HARDWARE.display.brightness.path) { const type = HARDWARE.session.type;
return null; const desktop = HARDWARE.session.desktop;
} const mapping = {
const max = fs.readFileSync(`${HARDWARE.display.brightness.path}/max_brightness`, "utf8").trim(); wayland: [{ command: "ddcutil", desktops: ["*"] }],
if (max) { x11: [{ command: "ddcutil", desktops: ["*"] }],
return parseInt(max, 10); }[type];
for (const map of mapping || []) {
if (sudoRights() && commandExists(map.command) && map.desktops.some((d) => d === "*" || desktop.includes(d))) {
HARDWARE.display.brightness.path = null;
switch (map.command) {
case "ddcutil":
const output = execSyncCommand("sudo", ["ddcutil", "capabilities"]);
if (output && output.includes("Feature: 10")) {
fs.writeFileSync(path.join(APP.cache, "Brightness.vcp"), "");
return map.command;
}
break;
default:
return map.command;
}
}
} }
return null; return null;
}; };
/** /**
* Gets the current display brightness level using `/sys/class/backlight/.../brightness`. * Gets the maximum display brightness value using `/sys/class/backlight/.../max_brightness` or `ddcutil getvcp 10`.
*
* @returns {number|null} The brightness maximum value or null if an error occurs.
*/
const getDisplayBrightnessMax = () => {
switch (HARDWARE.display.brightness.command) {
case "ddcutil":
const output = execSyncCommand("sudo", ["ddcutil", "getvcp", "10", "--brief"]);
const match = output ? output.match(/VCP 10 C (\d+) (\d+)/) : null;
if (match) {
return parseInt(match[2], 10);
}
return null;
}
if (HARDWARE.display.brightness.path) {
const max = readFile(path.join(HARDWARE.display.brightness.path, "max_brightness"));
if (max) {
return parseInt(max, 10);
}
}
return null;
};
/**
* Gets the current display brightness level using `/sys/class/backlight/.../brightness` or `ddcutil getvcp 10`.
* *
* @returns {number|null} The brightness level as a percentage or null if an error occurs. * @returns {number|null} The brightness level as a percentage or null if an error occurs.
*/ */
@@ -562,16 +692,27 @@ const getDisplayBrightness = () => {
if (!HARDWARE.support.displayBrightness) { if (!HARDWARE.support.displayBrightness) {
return null; return null;
} }
const brightness = fs.readFileSync(`${HARDWARE.display.brightness.path}/brightness`, "utf8").trim(); switch (HARDWARE.display.brightness.command) {
if (brightness) { case "ddcutil":
const max = HARDWARE.display.brightness.max || 1; const output = execSyncCommand("sudo", ["ddcutil", "getvcp", "10", "--brief"]);
return Math.max(1, Math.min(Math.round((parseInt(brightness, 10) / max) * 100), 100)); const match = output ? output.match(/VCP 10 C (\d+) (\d+)/) : null;
if (match) {
return parseInt(match[1], 10);
}
return null;
}
if (HARDWARE.display.brightness.path) {
const brightness = readFile(path.join(HARDWARE.display.brightness.path, "brightness"));
if (brightness) {
const max = HARDWARE.display.brightness.value.max || 1;
return Math.max(1, Math.min(Math.round((parseInt(brightness, 10) / max) * 100), 100));
}
} }
return null; return null;
}; };
/** /**
* Sets the display brightness level using `/sys/class/backlight/.../brightness`. * Sets the display brightness level using `/sys/class/backlight/.../brightness` or `ddcutil setvcp 10`.
* *
* This function takes a brightness value between 1 to 100 percent, * This function takes a brightness value between 1 to 100 percent,
* maps it to the proper range and writes it to the system. * maps it to the proper range and writes it to the system.
@@ -589,11 +730,82 @@ const setDisplayBrightness = (brightness, callback = null) => {
if (typeof callback === "function") callback(null, "Invalid brightness"); if (typeof callback === "function") callback(null, "Invalid brightness");
return; return;
} }
const max = HARDWARE.display.brightness.max || 1; switch (HARDWARE.display.brightness.command) {
const value = Math.max(1, Math.min(Math.round((brightness / 100) * max), max)); case "ddcutil":
const proc = execAsyncCommand("sudo", ["tee", `${HARDWARE.display.brightness.path}/brightness`], callback); execAsyncCommand("sudo", ["ddcutil", "setvcp", "10", `${brightness}`], (reply, error) => {
proc.stdin.write(value.toString()); if (!error) {
proc.stdin.end(); fs.writeFileSync(path.join(APP.cache, "Brightness.vcp"), `${brightness}`);
}
if (typeof callback === "function") callback(reply, error);
});
return;
}
if (HARDWARE.display.brightness.path) {
const max = HARDWARE.display.brightness.value.max || 1;
const value = Math.max(1, Math.min(Math.round((brightness / 100) * max), max));
const proc = execAsyncCommand("sudo", ["tee", path.join(HARDWARE.display.brightness.path, "brightness")], callback);
proc.stdin.write(value.toString());
proc.stdin.end();
}
};
/**
* Gets the default audio device using `pactl`.
*
* @returns {string|null} The default audio device or null if an error occurs.
*/
const getAudioDevice = () => {
if (!commandExists("pactl")) {
return null;
}
const output = execSyncCommand("pactl", ["get-default-sink"]);
if (!output) {
return null;
}
return !output.includes("auto_null") ? output.trim() : null;
};
/**
* Gets the default audio device volume using `pactl`.
*
* @returns {number|null} The default audio device volume as a percentage or null if an error occurs.
*/
const getAudioVolume = () => {
if (!HARDWARE.support.audioVolume) {
return null;
}
const mute = execSyncCommand("pactl", ["get-sink-mute", "@DEFAULT_SINK@"]);
const volume = execSyncCommand("pactl", ["get-sink-volume", "@DEFAULT_SINK@"]);
if (!mute || !volume) {
return null;
}
const match = volume.match(/\/(\s*(\d+)%)/);
if (match) {
return Math.max(0, Math.min(100, mute.includes("yes") ? 0 : parseInt(match[2], 10)));
}
return null;
};
/**
* Sets the default audio device volume using `pactl`.
*
* This function takes a volume value between 0 to 100 percent and sends it to the device.
*
* @param {number} volume - The desired volume level (0-100).
* @param {Function} callback - A callback function that receives the output or error.
*/
const setAudioVolume = (volume, callback = null) => {
if (!HARDWARE.support.audioVolume) {
if (typeof callback === "function") callback(null, "Not supported");
return;
}
if (typeof volume !== "number" || volume < 0 || volume > 100) {
console.error("Volume must be a number between 0 and 100");
if (typeof callback === "function") callback(null, "Invalid volume");
return;
}
execAsyncCommand("pactl", ["set-sink-mute", "@DEFAULT_SINK@", volume === 0 ? "1" : "0"]);
execAsyncCommand("pactl", ["set-sink-volume", "@DEFAULT_SINK@", `${volume}%`], callback);
}; };
/** /**
@@ -686,8 +898,8 @@ const rebootSystem = (callback = null) => {
*/ */
const sudoRights = () => { const sudoRights = () => {
try { try {
const output = cpr.execSync(`sudo -n true`, { encoding: "utf8" }); cpr.execSync(`sudo -n true`, { encoding: "utf8", stdio: "ignore" });
return output !== null && !output.includes("password is required"); return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -700,7 +912,8 @@ const sudoRights = () => {
*/ */
const serviceRuns = (name) => { const serviceRuns = (name) => {
try { try {
return !!cpr.execSync(`systemctl --user is-active ${name}`, { encoding: "utf8" }); cpr.execSync(`systemctl --user is-active ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -713,7 +926,8 @@ const serviceRuns = (name) => {
*/ */
const processRuns = (name) => { const processRuns = (name) => {
try { try {
return !!cpr.execSync(`pidof ${name}`, { encoding: "utf8" }); cpr.execSync(`pidof ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -726,7 +940,8 @@ const processRuns = (name) => {
*/ */
const commandExists = (name) => { const commandExists = (name) => {
try { try {
return !!cpr.execSync(`which ${name}`, { encoding: "utf8" }); cpr.execSync(`which ${name}`, { encoding: "utf8", stdio: "ignore" });
return true;
} catch {} } catch {}
return false; return false;
}; };
@@ -738,12 +953,13 @@ const commandExists = (name) => {
* @param {Array<string>} args - The arguments for the command. * @param {Array<string>} args - The arguments for the command.
* @returns {string|null} The output of the command or null if an error occurs. * @returns {string|null} The output of the command or null if an error occurs.
*/ */
const execSyncCommand = (cmd, args = []) => { const execSyncCommand = (cmd, args) => {
try { try {
console.debug(`hardware.js: execSyncCommand(${[cmd, ...args].join(" ")})`);
const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" }); const output = cpr.execSync([cmd, ...args].join(" "), { encoding: "utf8" });
return output.trim().replace(/\0/g, ""); return output.trim().replace(/\0/g, "");
} catch (error) { } catch (error) {
console.error("Execute Sync:", error.message); console.error(`Execute Sync: '${[cmd, ...args].join(" ")}' --> ${error.message}`.trim());
} }
return null; return null;
}; };
@@ -756,7 +972,8 @@ const execSyncCommand = (cmd, args = []) => {
* @param {Function} callback - A callback function that receives the output or error. * @param {Function} callback - A callback function that receives the output or error.
* @returns {Object} The spawned process object. * @returns {Object} The spawned process object.
*/ */
const execAsyncCommand = (cmd, args = [], callback = null) => { const execAsyncCommand = (cmd, args, callback = null) => {
console.debug(`hardware.js: execAsyncCommand(${[cmd, ...args].join(" ")})`);
let errorOutput = ""; let errorOutput = "";
let successOutput = ""; let successOutput = "";
let proc = cpr.spawn(cmd, args); let proc = cpr.spawn(cmd, args);
@@ -771,17 +988,13 @@ const execAsyncCommand = (cmd, args = [], callback = null) => {
} }
}); });
proc.on("close", (code) => { proc.on("close", (code) => {
try { const error = errorOutput.trim().replace(/\0/g, "");
if (typeof callback === "function") { const reply = successOutput.trim().replace(/\0/g, "");
if (code === 0) { if (code !== 0 || error) {
callback(successOutput.trim().replace(/\0/g, ""), null); console.error(`Execute Async: '${[cmd, ...args].join(" ")}' --> ${error} (${code})`);
} else { if (typeof callback === "function") callback(null, error);
callback(null, errorOutput.trim().replace(/\0/g, "")); } else {
} if (typeof callback === "function") callback(reply, null);
}
} catch (error) {
console.error("Execute Async:", error.message);
if (typeof callback === "function") callback(null, error.message);
} }
}); });
return proc; return proc;
@@ -795,27 +1008,28 @@ const execAsyncCommand = (cmd, args = [], callback = null) => {
* @param {Function} callback - A callback function that receives the progress or error. * @param {Function} callback - A callback function that receives the progress or error.
* @returns {Object} The spawned process object. * @returns {Object} The spawned process object.
*/ */
const execScriptCommand = (cmd, args = [], callback = null) => { const execScriptCommand = (cmd, args, callback = null) => {
let progress = 10; console.debug(`hardware.js: execScriptCommand(${[cmd, ...args].join(" ")})`);
let progress = 1;
let proc = cpr.spawn(cmd, args); let proc = cpr.spawn(cmd, args);
if (typeof callback === "function") callback(progress, null); if (typeof callback === "function") callback(progress, null);
proc.stdout.on("data", (data) => { proc.stdout.on("data", (data) => {
if (data) { if (data) {
const lines = data.toString().trim(); const output = data.toString().trim();
const output = lines.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, ""); const lines = output.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, "");
if (output) { for (const line of lines.split("\n").filter(Boolean)) {
console.log(output); console.info(line);
} }
} }
}); });
proc.stderr.on("data", (data) => { proc.stderr.on("data", (data) => {
if (data) { if (data) {
const lines = data.toString().trim(); const output = data.toString().trim();
const output = lines.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, ""); const lines = output.replace(/\n+/g, "\n").replace(/^\n+|\n+$|\0/g, "");
if (output) { for (const line of lines.split("\n").filter(Boolean)) {
const matches = output.match(/(\d{1,3})%/g); const matches = line.match(/(\d{1,3})%/g);
if (matches) { if (matches) {
const match = Math.max(...matches.map((p) => parseInt(p))); const match = Math.max(...matches.map((p) => parseInt(p, 10)));
const percent = Math.floor(match / 10) * 10; const percent = Math.floor(match / 10) * 10;
if (percent > 10 && percent > progress) { if (percent > 10 && percent > progress) {
progress = percent; progress = percent;
@@ -827,35 +1041,39 @@ const execScriptCommand = (cmd, args = [], callback = null) => {
}); });
proc.on("close", (code) => { proc.on("close", (code) => {
if (code !== 0) { if (code !== 0) {
console.log(`Script exited with error code ${code}.`); console.error(`Script exited with error code (${code}).`);
if (typeof callback === "function") callback(null, code); if (typeof callback === "function") callback(null, code);
} else { } else {
console.log("Script exited successfully."); console.info("Script exited successfully.");
if (typeof callback === "function") callback(100, null); if (typeof callback === "function") callback(100, null);
} }
}); });
}; };
/** /**
* Executes a D-Bus method call synchronously using `dbus-send`. * Monitors a command asynchronously.
* *
* @param {string} path - The D-Bus object path. * @param {string} cmd - The command to monitor.
* @param {string} method - The D-Bus method name. * @param {Array<string>} args - The arguments for the command.
* @param {Array<string>} values - The argument values for the D-Bus method.
* @param {Function} callback - A callback function that receives the output or error. * @param {Function} callback - A callback function that receives the output or error.
* @returns {Object} The spawned process object.
*/ */
const dbusCall = (path, method, values, callback = null) => { const commandMonitor = (cmd, args, callback = null) => {
const cmd = "dbus-send"; console.debug(`hardware.js: commandMonitor(${[cmd, ...args].join(" ")})`);
const iface = path.slice(1).replace(/\//g, "."); const proc = cpr.spawn(cmd, args);
const dest = `${iface} ${path} ${iface}.${method} ${values.join(" ")}`; proc.stdout.on("data", (data) => {
const args = ["--print-reply", "--type=method_call", `--dest=${dest}`]; if (data) {
try { const output = data.toString().trim().replace(/\0/g, "");
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" }); if (typeof callback === "function") callback(output, null);
if (typeof callback === "function") callback(output.trim().replace(/\0/g, ""), null); }
} catch (error) { });
console.error("Call D-Bus:", error.message); proc.stderr.on("data", (data) => {
if (typeof callback === "function") callback(null, error.message); if (data) {
} const output = data.toString().trim().replace(/\0/g, "");
if (typeof callback === "function") callback(null, output);
}
});
return proc;
}; };
/** /**
@@ -865,25 +1083,28 @@ const dbusCall = (path, method, values, callback = null) => {
* @param {Function} callback - A callback function that receives the changed property. * @param {Function} callback - A callback function that receives the changed property.
* @returns {Object} The spawned process object. * @returns {Object} The spawned process object.
*/ */
const dbusMonitor = (path, callback) => { const dbusMonitor = (path, callback = null) => {
const cmd = "dbus-monitor"; const cmd = "dbus-monitor";
const args = [`interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='${path}'`]; const args = [`interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path='${path}'`];
console.debug(`hardware.js: dbusMonitor(${[cmd, ...args].join(" ")})`);
const proc = cpr.spawn(cmd, args); const proc = cpr.spawn(cmd, args);
proc.stdout.on("data", (data) => { proc.stdout.on("data", (data) => {
try { try {
const signal = data.toString(); const signal = data.toString();
if (signal.includes("member=PropertiesChanged") && signal.includes(`path=${path}`)) { if (signal.includes(`string "Visible"`)) {
const dicts = [...signal.matchAll(/dict entry\(\s*([^)]*?)\)/g)].map((dict) => dict[1].trim()); const dicts = [...signal.matchAll(/dict entry\(\s*([^)]*?)\)/g)].map((dict) => dict[1].trim());
if (dicts.length) { if (dicts.length) {
dicts.forEach((dict) => { dicts.forEach((dict) => {
const key = dict.match(/string "(.*?)"/); const key = dict.match(/string "(.*?)"/);
const value = dict.match(/(?<=variant\s+)(.*)/); const value = dict.match(/(?<=variant\s+)(.*)/);
if (key && value && typeof callback === "function") { if (key && value) {
callback({ [key[1].trim()]: value[1].trim().split(" ").pop() }, null); const property = { [key[1].trim()]: value[1].trim().split(" ").pop() };
if (typeof callback === "function") callback(property, null);
} }
}); });
} else { } else {
callback({ Visible: `${HARDWARE.keyboard.visibility}` }, null); const property = { Visible: `${HARDWARE.keyboard.visibility}` };
if (typeof callback === "function") callback(property, null);
} }
} }
} catch (error) { } catch (error) {
@@ -900,6 +1121,54 @@ const dbusMonitor = (path, callback) => {
return proc; return proc;
}; };
/**
* Executes a D-Bus method call synchronously using `dbus-send`.
*
* @param {string} path - The D-Bus object path.
* @param {string} method - The D-Bus method name.
* @param {Array<string>} values - The argument values for the D-Bus method.
* @param {Function} callback - A callback function that receives the output or error.
*/
const dbusCall = (path, method, values, callback = null) => {
const cmd = "dbus-send";
const iface = path.slice(1).replace(/\//g, ".");
const dest = `${iface} ${path} ${iface}.${method} ${values.join(" ")}`;
const args = ["--print-reply", "--type=method_call", `--dest=${dest}`];
try {
console.debug(`hardware.js: dbusCall(${[cmd, ...args].join(" ")})`);
const output = cpr.execSync([cmd, ...args].join(" ").trim(), { encoding: "utf8" });
if (typeof callback === "function") callback(output.trim().replace(/\0/g, ""), null);
} catch (error) {
console.error("Call D-Bus:", error.message);
if (typeof callback === "function") callback(null, error.message);
}
};
/**
* Reads file content synchronously or asynchronously from the filesystem.
*
* @param {string} path - Path of the file.
* @param {boolean} sync - If true, reads the file synchronously, otherwise asynchronously.
* @returns {string|null|Promise<string|null>} The file content or null if an error occurs.
*/
const readFile = (path, sync = true) => {
if (!sync) {
return fsp
.readFile(path, "utf8")
.then((content) => content.trim())
.catch((error) => {
console.error(`Read File ${path} Async:`, error.message);
return null;
});
}
try {
return fs.readFileSync(path, "utf8").trim();
} catch (error) {
console.error(`Read File ${path} Sync:`, error.message);
}
return null;
};
/** /**
* Helper function for asynchronous interval calls. * Helper function for asynchronous interval calls.
* *
@@ -912,7 +1181,7 @@ const interval = (callback, ms) => {
try { try {
await callback(); await callback();
} catch (error) { } catch (error) {
console.error("Interval Callback:", error.message); console.error("Interval Async:", error.message);
} }
run(); run();
}, ms); }, ms);
@@ -939,6 +1208,8 @@ module.exports = {
setDisplayStatus, setDisplayStatus,
getDisplayBrightness, getDisplayBrightness,
setDisplayBrightness, setDisplayBrightness,
getAudioVolume,
setAudioVolume,
getKeyboardVisibility, getKeyboardVisibility,
setKeyboardVisibility, setKeyboardVisibility,
checkPackageUpgrades, checkPackageUpgrades,
+379 -154
View File
@@ -1,11 +1,9 @@
const mqtt = require("mqtt"); const mqtt = require("mqtt");
const hardware = require("./hardware"); const hardware = require("./hardware");
const { app } = require("electron"); const { app } = require("electron");
const Events = require("events");
global.INTEGRATION = global.INTEGRATION || { global.INTEGRATION = global.INTEGRATION || {
initialized: false, initialized: false,
events: new Events(),
}; };
/** /**
@@ -50,16 +48,22 @@ const init = async () => {
configuration_url: APP.homepage, configuration_url: APP.homepage,
}; };
// Connection settings // Init options
const options = user === null || password === null ? null : { username: user, password: password }; const masked = password === null ? null : "*".repeat(password.length);
const masked = password === null ? "null" : "*".repeat(password.length); const options = user === null || password === null ? {} : { username: user, password: password };
console.log("MQTT Connecting:", `${user}:${masked}@${url.toString()}`); options.will = { topic: `${INTEGRATION.root}/kiosk/state`, payload: "Terminated", qos: 1, retain: true };
options.rejectUnauthorized = !("ignore_certificate_errors" in ARGS);
// Client connect
console.info("MQTT Connecting:", `${user}:${masked}@${url.toString()}`);
INTEGRATION.client = mqtt.connect(url.toString(), options); INTEGRATION.client = mqtt.connect(url.toString(), options);
INTEGRATION.client.setMaxListeners(20);
// Client connected // Client connected
INTEGRATION.client INTEGRATION.client
.once("connect", () => { .once("connect", () => {
console.log(`MQTT Connected: ${url.toString()}\n`); console.info(`MQTT Connected: ${url.toString()}`);
process.stdout.write("\n");
// Init client controls // Init client controls
initApp(); initApp();
@@ -67,7 +71,9 @@ const init = async () => {
initReboot(); initReboot();
initRefresh(); initRefresh();
initKiosk(); initKiosk();
initTheme();
initDisplay(); initDisplay();
initVolume();
initKeyboard(); initKeyboard();
initPageNumber(); initPageNumber();
initPageZoom(); initPageZoom();
@@ -85,34 +91,62 @@ const init = async () => {
initProcessorTemperature(); initProcessorTemperature();
initBatteryLevel(); initBatteryLevel();
initPackageUpgrades(); initPackageUpgrades();
initHeartbeat();
initLastActive(); initLastActive();
// Init client diagnostic
initScreenshot();
initHeartbeat();
initErrors();
initVersion();
// Integration initialized // Integration initialized
INTEGRATION.initialized = true; INTEGRATION.initialized = true;
// Register global events
EVENTS.on("updateApp", updateApp);
EVENTS.on("updateStatus", updateKiosk);
EVENTS.on("updateVolume", updateVolume);
EVENTS.on("updateKeyboard", updateKeyboard);
EVENTS.on("updatePage", () => {
updatePageNumber();
updatePageZoom();
updatePageUrl();
updateTheme();
});
EVENTS.on("updateDisplay", () => {
updateDisplay();
updateLastActive();
});
EVENTS.on("updateScreenshot", updateScreenshot);
EVENTS.on("consoleLog", updateErrors);
}) })
.on("error", (error) => { .on("error", (error) => {
console.error("MQTT", error.message); console.error("MQTT", error.message);
}); });
// Update sensor states from events
HARDWARE.events.on("updateDisplay", updateDisplay);
HARDWARE.events.on("updateKeyboard", updateKeyboard);
// Update time sensors periodically (30s) // Update time sensors periodically (30s)
setInterval(() => { setInterval(() => {
updateHeartbeat(); if (APP.exiting) {
return;
}
updateLastActive(); updateLastActive();
updateHeartbeat();
updateErrors();
}, 30 * 1000); }, 30 * 1000);
// Update system sensors periodically (1min) // Update system sensors periodically (1min)
setInterval(() => { setInterval(() => {
if (APP.exiting) {
return;
}
update(); update();
}, 60 * 1000); }, 60 * 1000);
// Update upgrade sensors periodically (1h) // Update upgrade sensors periodically (1h)
setInterval(() => { setInterval(() => {
updateApp(); if (APP.exiting) {
return;
}
updatePackageUpgrades(); updatePackageUpgrades();
}, 3600 * 1000); }, 3600 * 1000);
@@ -123,15 +157,11 @@ const init = async () => {
* Updates the shared integration properties. * Updates the shared integration properties.
*/ */
const update = async () => { const update = async () => {
if (!INTEGRATION.initialized) { if (!INTEGRATION.initialized || APP.exiting) {
return; return;
} }
console.debug("integration.js: update()");
// Update client sensors
updateKiosk();
updatePageNumber();
updatePageZoom();
updatePageUrl();
updateNetworkAddress(); updateNetworkAddress();
updateUpTime(); updateUpTime();
updateLastActive(); updateLastActive();
@@ -141,6 +171,23 @@ const update = async () => {
updateBatteryLevel(); updateBatteryLevel();
}; };
/**
* Removes 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 removeConfig = (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`;
console.debug(`integration.js: removeConfig(${path})`);
return INTEGRATION.client.publish(root, JSON.stringify({}), { qos: 1, retain: true });
};
/** /**
* Publishes the auto-discovery config via the mqtt connection. * Publishes the auto-discovery config via the mqtt connection.
* *
@@ -154,6 +201,7 @@ const publishConfig = (type, config) => {
} }
const path = config.unique_id.replace(`${INTEGRATION.node}_`, ""); const path = config.unique_id.replace(`${INTEGRATION.node}_`, "");
const root = `${INTEGRATION.discovery}/${type}/${INTEGRATION.node}/${path}/config`; const root = `${INTEGRATION.discovery}/${type}/${INTEGRATION.node}/${path}/config`;
console.debug(`integration.js: publishConfig(${path})`);
return INTEGRATION.client.publish(root, JSON.stringify(config), { qos: 1, retain: true }); return INTEGRATION.client.publish(root, JSON.stringify(config), { qos: 1, retain: true });
}; };
@@ -191,26 +239,31 @@ const publishState = (path, state) => {
* Initializes the app update entity and handles the execute logic. * Initializes the app update entity and handles the execute logic.
*/ */
const initApp = () => { const initApp = () => {
if (!HARDWARE.support.appUpdate) {
return;
}
const root = `${INTEGRATION.root}/app`; const root = `${INTEGRATION.root}/app`;
const config = { const config = {
name: "App", name: "App",
unique_id: `${INTEGRATION.node}_app`, unique_id: `${INTEGRATION.node}_app`,
command_topic: `${root}/install`, command_topic: `${root}/install`,
state_topic: `${root}/version/state`, state_topic: `${root}/version/state`,
payload_install: "update", payload_install: "app_early" in ARGS ? "update early" : "update",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (!HARDWARE.support.appUpdate) {
removeConfig("update", config);
return;
}
publishConfig("update", config) publishConfig("update", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
console.log("Update App..."); console.info("Update App...");
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON", () => {
const args = ["-c", `bash <(wget -qO- ${APP.scripts.install}) update`]; const args = ["-c", `bash <(wget -qO- ${APP.scripts.install}) ${message.toString()}`];
hardware.execScriptCommand("bash", args, (progress, error) => { hardware.execScriptCommand("bash", args, (progress, error) => {
updateApp(progress); if (progress) {
console.info(`Progress: ${progress}%`);
}
updateApp(progress);
});
}); });
} }
}) })
@@ -223,14 +276,15 @@ const initApp = () => {
*/ */
const updateApp = async (progress = 0) => { const updateApp = async (progress = 0) => {
const latest = APP.releases.latest; const latest = APP.releases.latest;
if (!latest) { if (!HARDWARE.support.appUpdate || !latest || !latest.summary) {
return; return;
} }
const summary = latest.summary.length > 250 ? latest.summary.slice(0, 250) + "..." : latest.summary;
const version = { const version = {
title: latest.title, title: latest.title,
latest_version: latest.version, latest_version: latest.version,
installed_version: APP.version, installed_version: APP.version,
release_summary: latest.summary.slice(0, 250) + "...", release_summary: summary,
release_url: latest.url, release_url: latest.url,
update_percentage: progress || null, update_percentage: progress || null,
in_progress: progress && progress > 0 && progress < 100, in_progress: progress && progress > 0 && progress < 100,
@@ -242,9 +296,6 @@ const updateApp = async (progress = 0) => {
* Initializes the shutdown button and handles the execute logic. * Initializes the shutdown button and handles the execute logic.
*/ */
const initShutdown = () => { const initShutdown = () => {
if (!HARDWARE.support.sudoRights) {
return;
}
const root = `${INTEGRATION.root}/shutdown`; const root = `${INTEGRATION.root}/shutdown`;
const config = { const config = {
name: "Shutdown", name: "Shutdown",
@@ -253,12 +304,17 @@ const initShutdown = () => {
icon: "mdi:power", icon: "mdi:power",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (!HARDWARE.support.sudoRights) {
removeConfig("button", config);
return;
}
publishConfig("button", config) publishConfig("button", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
console.log("Shutdown system..."); console.verbose("Shutdown system...");
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON", () => {
hardware.shutdownSystem(); hardware.shutdownSystem();
});
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -268,9 +324,6 @@ const initShutdown = () => {
* Initializes the reboot button and handles the execute logic. * Initializes the reboot button and handles the execute logic.
*/ */
const initReboot = () => { const initReboot = () => {
if (!HARDWARE.support.sudoRights) {
return;
}
const root = `${INTEGRATION.root}/reboot`; const root = `${INTEGRATION.root}/reboot`;
const config = { const config = {
name: "Reboot", name: "Reboot",
@@ -279,12 +332,17 @@ const initReboot = () => {
icon: "mdi:restart", icon: "mdi:restart",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (!HARDWARE.support.sudoRights) {
removeConfig("button", config);
return;
}
publishConfig("button", config) publishConfig("button", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
console.log("Rebooting system..."); console.verbose("Rebooting system...");
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON", () => {
hardware.rebootSystem(); hardware.rebootSystem();
});
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -305,9 +363,10 @@ const initRefresh = () => {
publishConfig("button", config) publishConfig("button", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
console.log("Refreshing webview..."); console.verbose("Refreshing webview...");
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON", () => {
WEBVIEW.events.emit("reloadView"); EVENTS.emit("reloadView");
});
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -332,32 +391,10 @@ const initKiosk = () => {
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const status = message.toString(); const status = message.toString();
console.log("Set Kiosk Status:", status); console.verbose("Set Kiosk Status:", status);
hardware.setDisplayStatus("ON"); hardware.setDisplayStatus("ON", () => {
switch (status) { WEBVIEW.window.setStatus(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); .subscribe(config.command_topic);
@@ -368,52 +405,90 @@ const initKiosk = () => {
* Updates the kiosk status via the mqtt connection. * Updates the kiosk status via the mqtt connection.
*/ */
const updateKiosk = async () => { const updateKiosk = async () => {
const kiosk = WEBVIEW.status; const kiosk = WEBVIEW.tracker.window.status;
publishState("kiosk", kiosk); publishState("kiosk", kiosk);
}; };
/**
* Initializes the application theme and handles the execute logic.
*/
const initTheme = () => {
const root = `${INTEGRATION.root}/theme`;
const config = {
name: "Theme",
unique_id: `${INTEGRATION.node}_theme`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value }}",
options: ["Light", "Dark"],
icon: "mdi:compare",
device: INTEGRATION.device,
};
publishConfig("select", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const theme = message.toString().toLowerCase();
console.verbose("Set Application Theme:", theme);
WEBVIEW.theme.set(theme);
}
})
.subscribe(config.command_topic);
updateTheme();
};
/**
* Updates the application theme via the mqtt connection.
*/
const updateTheme = async () => {
const theme = WEBVIEW.theme.get();
publishState("theme", theme.charAt(0).toUpperCase() + theme.slice(1));
};
/** /**
* Initializes the display status, brightness and handles the execute logic. * Initializes the display status, brightness and handles the execute logic.
*/ */
const initDisplay = () => { const initDisplay = () => {
if (!HARDWARE.support.displayStatus) {
return;
}
const root = `${INTEGRATION.root}/display`; const root = `${INTEGRATION.root}/display`;
const config = { const config = {
name: "Display", name: "Display",
unique_id: `${INTEGRATION.node}_display`, unique_id: `${INTEGRATION.node}_display`,
command_topic: `${root}/power/set`, command_topic: `${root}/power/set`,
state_topic: `${root}/power/state`, state_topic: `${root}/power/state`,
supported_color_modes: ["onoff"],
icon: "mdi:monitor-shimmer", icon: "mdi:monitor-shimmer",
platform: "light", platform: "light",
device: INTEGRATION.device, device: INTEGRATION.device,
...(HARDWARE.support.displayBrightness && { ...(HARDWARE.support.displayBrightness && {
supported_color_modes: ["brightness"],
brightness_command_topic: `${root}/brightness/set`, brightness_command_topic: `${root}/brightness/set`,
brightness_state_topic: `${root}/brightness/state`, brightness_state_topic: `${root}/brightness/state`,
brightness_scale: 100, brightness_scale: 100,
}), }),
}; };
if (!HARDWARE.support.displayStatus) {
removeConfig("light", config);
return;
}
publishConfig("light", config) publishConfig("light", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const status = message.toString(); const status = message.toString();
console.log("Set Display Status:", status); console.verbose("Set Display Status:", status);
hardware.setDisplayStatus(status, (reply, error) => { hardware.setDisplayStatus(status, (reply, error) => {
if (error) { if (!error) {
console.warn("Failed:", error);
} else {
hardware.update(); hardware.update();
} else {
console.warn("Command Failed:", error);
} }
}); });
} else if (topic === config.brightness_command_topic) { } else if (topic === config.brightness_command_topic) {
const brightness = parseInt(message, 10); const brightness = parseInt(message, 10);
console.log("Set Display Brightness:", brightness); console.verbose("Set Display Brightness:", brightness);
hardware.setDisplayBrightness(brightness, (reply, error) => { hardware.setDisplayBrightness(brightness, (reply, error) => {
if (error) { if (!error) {
console.warn("Failed:", error);
} else {
hardware.update(); hardware.update();
} else {
console.warn("Command Failed:", error);
} }
}); });
} }
@@ -429,17 +504,56 @@ const initDisplay = () => {
const updateDisplay = async () => { const updateDisplay = async () => {
const status = hardware.getDisplayStatus(); const status = hardware.getDisplayStatus();
const brightness = hardware.getDisplayBrightness(); const brightness = hardware.getDisplayBrightness();
publishState("display/power", status);
publishState("display/brightness", brightness); publishState("display/brightness", brightness);
publishState("display/power", status);
};
/**
* Initializes the audio volume and handles the execute logic.
*/
const initVolume = () => {
const root = `${INTEGRATION.root}/volume`;
const config = {
name: "Volume",
unique_id: `${INTEGRATION.node}_volume`,
command_topic: `${root}/set`,
state_topic: `${root}/state`,
value_template: "{{ value | int }}",
mode: "slider",
min: 0,
max: 100,
unit_of_measurement: "%",
icon: "mdi:volume-high",
device: INTEGRATION.device,
};
if (!HARDWARE.support.audioVolume) {
removeConfig("number", config);
return;
}
publishConfig("number", config)
.on("message", (topic, message) => {
if (topic === config.command_topic) {
const volume = parseInt(message, 10);
console.verbose("Set Audio Volume:", volume);
hardware.setAudioVolume(volume);
}
})
.subscribe(config.command_topic);
updateVolume();
};
/**
* Updates the audio volume via the mqtt connection.
*/
const updateVolume = async () => {
const volume = hardware.getAudioVolume();
publishState("volume", volume);
}; };
/** /**
* Initializes the keyboard visibility and handles the execute logic. * Initializes the keyboard visibility and handles the execute logic.
*/ */
const initKeyboard = () => { const initKeyboard = () => {
if (!HARDWARE.support.keyboardVisibility) {
return;
}
const root = `${INTEGRATION.root}/keyboard`; const root = `${INTEGRATION.root}/keyboard`;
const config = { const config = {
name: "Keyboard", name: "Keyboard",
@@ -449,24 +563,18 @@ const initKeyboard = () => {
icon: "mdi:keyboard-close-outline", icon: "mdi:keyboard-close-outline",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (!HARDWARE.support.keyboardVisibility) {
removeConfig("switch", config);
return;
}
publishConfig("switch", config) publishConfig("switch", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const status = message.toString(); const status = message.toString();
console.log("Set Keyboard Visibility:", status); console.verbose("Set Keyboard Visibility:", status);
hardware.setKeyboardVisibility(status); hardware.setDisplayStatus("ON", () => {
switch (status) { hardware.setKeyboardVisibility(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); .subscribe(config.command_topic);
@@ -485,9 +593,6 @@ const updateKeyboard = async () => {
* Initializes the page number and handles the execute logic. * Initializes the page number and handles the execute logic.
*/ */
const initPageNumber = () => { const initPageNumber = () => {
if (WEBVIEW.viewUrls.length <= 2) {
return;
}
const root = `${INTEGRATION.root}/page_number`; const root = `${INTEGRATION.root}/page_number`;
const config = { const config = {
name: "Page Number", name: "Page Number",
@@ -502,13 +607,19 @@ const initPageNumber = () => {
icon: "mdi:page-next", icon: "mdi:page-next",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (WEBVIEW.viewUrls.length <= 2) {
removeConfig("number", config);
return;
}
publishConfig("number", config) publishConfig("number", config)
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const number = parseInt(message, 10); const number = parseInt(message, 10);
console.log("Set Page Number:", number); if (WEBVIEW.viewActive && number) {
WEBVIEW.viewActive = number || 1; console.verbose("Set Page Number:", number);
WEBVIEW.events.emit("updateView"); WEBVIEW.viewActive = number;
EVENTS.emit("updateView");
}
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -519,7 +630,7 @@ const initPageNumber = () => {
* Updates the page number via the mqtt connection. * Updates the page number via the mqtt connection.
*/ */
const updatePageNumber = async () => { const updatePageNumber = async () => {
const pageNumber = WEBVIEW.viewActive || 1; const pageNumber = WEBVIEW.viewUrls.length <= 2 ? null : WEBVIEW.viewActive || 1;
publishState("page_number", pageNumber); publishState("page_number", pageNumber);
}; };
@@ -534,9 +645,10 @@ const initPageZoom = () => {
command_topic: `${root}/set`, command_topic: `${root}/set`,
state_topic: `${root}/state`, state_topic: `${root}/state`,
value_template: "{{ value | int }}", value_template: "{{ value | int }}",
mode: "slider", mode: "box",
min: 25, min: 25,
max: 400, max: 400,
step: 5,
unit_of_measurement: "%", unit_of_measurement: "%",
icon: "mdi:magnify-plus", icon: "mdi:magnify-plus",
device: INTEGRATION.device, device: INTEGRATION.device,
@@ -545,9 +657,11 @@ const initPageZoom = () => {
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const zoom = parseInt(message, 10); const zoom = parseInt(message, 10);
console.log("Set Page Zoom:", zoom); if (WEBVIEW.viewActive && zoom) {
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.setZoomFactor(zoom / 100.0); console.verbose("Set Page Zoom:", zoom);
WEBVIEW.events.emit("updateView"); WEBVIEW.zoom.set(zoom);
EVENTS.emit("updateView");
}
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -558,7 +672,7 @@ const initPageZoom = () => {
* Updates the page zoom via the mqtt connection. * Updates the page zoom via the mqtt connection.
*/ */
const updatePageZoom = async () => { const updatePageZoom = async () => {
const pageZoom = Math.round(WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getZoomFactor() * 100.0); const pageZoom = WEBVIEW.zoom.get();
publishState("page_zoom", pageZoom); publishState("page_zoom", pageZoom);
}; };
@@ -581,8 +695,10 @@ const initPageUrl = () => {
.on("message", (topic, message) => { .on("message", (topic, message) => {
if (topic === config.command_topic) { if (topic === config.command_topic) {
const url = message.toString(); const url = message.toString();
console.log("Set Page Url:", url); if (WEBVIEW.viewActive && url) {
WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.loadURL(url); console.verbose("Set Page Url:", url);
WEBVIEW.views[WEBVIEW.viewActive].webContents.loadURL(url);
}
} }
}) })
.subscribe(config.command_topic); .subscribe(config.command_topic);
@@ -596,7 +712,7 @@ const updatePageUrl = async () => {
const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive || 1]; const defaultUrl = WEBVIEW.viewUrls[WEBVIEW.viewActive || 1];
const currentUrl = WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getURL(); const currentUrl = WEBVIEW.views[WEBVIEW.viewActive || 1].webContents.getURL();
const pageUrl = !currentUrl || currentUrl.startsWith("data:") ? defaultUrl : currentUrl; const pageUrl = !currentUrl || currentUrl.startsWith("data:") ? defaultUrl : currentUrl;
publishState("page_url", pageUrl); publishState("page_url", pageUrl.length < 255 ? pageUrl : null);
}; };
/** /**
@@ -715,6 +831,7 @@ const initUpTime = () => {
name: "Up Time", name: "Up Time",
unique_id: `${INTEGRATION.node}_up_time`, unique_id: `${INTEGRATION.node}_up_time`,
state_topic: `${root}/state`, state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ (value | float) | round(0) }}", value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min", unit_of_measurement: "min",
icon: "mdi:timeline-clock", icon: "mdi:timeline-clock",
@@ -729,7 +846,11 @@ const initUpTime = () => {
*/ */
const updateUpTime = async () => { const updateUpTime = async () => {
const upTime = hardware.getUpTime(); const upTime = hardware.getUpTime();
const bootTime = {
boot: new Date(new Date().getTime() - upTime * 60 * 1000),
};
publishState("up_time", upTime); publishState("up_time", upTime);
publishAttributes("up_time", bootTime);
}; };
/** /**
@@ -840,9 +961,6 @@ const updateProcessorTemperature = async () => {
* Initializes the battery level sensor. * Initializes the battery level sensor.
*/ */
const initBatteryLevel = () => { const initBatteryLevel = () => {
if (!HARDWARE.support.batteryLevel) {
return;
}
const root = `${INTEGRATION.root}/battery_level`; const root = `${INTEGRATION.root}/battery_level`;
const config = { const config = {
name: "Battery Level", name: "Battery Level",
@@ -853,6 +971,10 @@ const initBatteryLevel = () => {
icon: "mdi:battery-medium", icon: "mdi:battery-medium",
device: INTEGRATION.device, device: INTEGRATION.device,
}; };
if (!HARDWARE.support.batteryLevel) {
removeConfig("sensor", config);
return;
}
publishConfig("sensor", config); publishConfig("sensor", config);
updateBatteryLevel(); updateBatteryLevel();
}; };
@@ -888,39 +1010,14 @@ const initPackageUpgrades = () => {
*/ */
const updatePackageUpgrades = async () => { const updatePackageUpgrades = async () => {
const packages = hardware.checkPackageUpgrades(); const packages = hardware.checkPackageUpgrades();
const attributes = { const upgrades = {
total: packages.length, packages: packages.map((pkg) => {
packages: packages.map((pkg) => pkg.replace(/\s*\[.*?\]\s*/g, "").trim()), const [name, version] = pkg.replace(/\s*\[.*?\]\s*/g, "").split(/\s+/, 2);
return { [name]: version };
}),
}; };
publishState("package_upgrades", attributes.total); publishState("package_upgrades", packages.length);
publishAttributes("package_upgrades", attributes); publishAttributes("package_upgrades", upgrades);
};
/**
* 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 = async () => {
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);
}; };
/** /**
@@ -932,6 +1029,7 @@ const initLastActive = () => {
name: "Last Active", name: "Last Active",
unique_id: `${INTEGRATION.node}_last_active`, unique_id: `${INTEGRATION.node}_last_active`,
state_topic: `${root}/state`, state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ (value | float) | round(0) }}", value_template: "{{ (value | float) | round(0) }}",
unit_of_measurement: "min", unit_of_measurement: "min",
icon: "mdi:gesture-tap-hold", icon: "mdi:gesture-tap-hold",
@@ -946,9 +1044,136 @@ const initLastActive = () => {
*/ */
const updateLastActive = async () => { const updateLastActive = async () => {
const now = new Date(); const now = new Date();
const then = WEBVIEW.pointer.time; const then = WEBVIEW.tracker.pointer.time;
const lastActive = Math.abs(now - then) / (1000 * 60); const lastActive = (now - then) / (1000 * 60);
const tracker = {
...WEBVIEW.tracker.pointer.position,
...WEBVIEW.tracker.display,
};
publishState("last_active", lastActive); publishState("last_active", lastActive);
publishAttributes("last_active", tracker);
};
/**
* Initializes the page screenshot.
*/
const initScreenshot = () => {
const root = `${INTEGRATION.root}/screenshot`;
const config = {
name: "Screenshot",
unique_id: `${INTEGRATION.node}_screenshot`,
image_topic: `${root}/state`,
image_encoding: "b64",
content_type: "image/png",
entity_category: "diagnostic",
icon: "mdi:image-area",
device: INTEGRATION.device,
};
publishConfig("image", config);
updateScreenshot();
};
/**
* Updates the page screenshot via the mqtt connection.
*/
const updateScreenshot = async () => {
const screenshot = WEBVIEW.tracker.screenshot;
publishState("screenshot", screenshot);
};
/**
* Initializes the heartbeat sensor.
*/
const initHeartbeat = () => {
const root = `${INTEGRATION.root}/heartbeat`;
const config = {
name: "Heartbeat",
unique_id: `${INTEGRATION.node}_heartbeat`,
state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value }}",
entity_category: "diagnostic",
icon: "mdi:heart-flash",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateHeartbeat();
};
/**
* Updates the heartbeat sensor via the mqtt connection.
*/
const updateHeartbeat = async () => {
const now = new Date();
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000);
const heartbeat = local.toISOString().replace(/\.\d{3}Z$/, "");
const attributes = { date: now };
publishState("heartbeat", heartbeat);
publishAttributes("heartbeat", attributes);
};
/**
* Initializes the error log sensor.
*/
const initErrors = () => {
const root = `${INTEGRATION.root}/errors`;
const config = {
name: "Errors",
unique_id: `${INTEGRATION.node}_errors`,
state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value | int }}",
entity_category: "diagnostic",
icon: "mdi:alert-circle",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateErrors();
};
/**
* Updates the error log sensor via the mqtt connection.
*/
const updateErrors = async () => {
const logs = APP.logs.slice().reverse();
const errors = logs.filter((log) => log.level === "error");
const history = logs.reduce((acc, log) => {
const time = log.time.toISOString().slice(0, 16);
if (!acc[time]) {
acc[time] = [];
}
acc[time].push({ [log.level.toUpperCase()]: log.text });
return acc;
}, {});
publishState("errors", errors.length);
publishAttributes("errors", history);
};
/**
* Initializes the version sensor.
*/
const initVersion = () => {
const root = `${INTEGRATION.root}/version`;
const config = {
name: "Version",
unique_id: `${INTEGRATION.node}_version`,
state_topic: `${root}/state`,
json_attributes_topic: `${root}/attributes`,
value_template: "{{ value }}",
entity_category: "diagnostic",
icon: "mdi:application-braces",
device: INTEGRATION.device,
};
publishConfig("sensor", config);
updateVersion();
};
/**
* Updates the version sensor via the mqtt connection.
*/
const updateVersion = async () => {
publishState("version", APP.version);
publishAttributes("version", APP.build);
}; };
module.exports = { module.exports = {
+804 -247
View File
File diff suppressed because it is too large Load Diff
+9 -8
View File
@@ -4,27 +4,28 @@
"description": "Home Assistant Touch Kiosk", "description": "Home Assistant Touch Kiosk",
"homepage": "https://github.com/leukipp/touchkio", "homepage": "https://github.com/leukipp/touchkio",
"author": "leukipp", "author": "leukipp",
"version": "1.3.0", "version": "1.4.1",
"license": "MIT", "license": "MIT",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "electron . --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app --app-debug=false --trace-warnings", "start": "electron . --web-url=https://demo.home-assistant.io,https://demo.immichkiosk.app --trace-warnings",
"build": "electron-forge make" "build": "electron-forge make"
}, },
"prettier": { "prettier": {
"printWidth": 120 "printWidth": 120
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.8.3", "@electron-forge/cli": "^7.10.2",
"@electron-forge/maker-deb": "^7.8.3", "@electron-forge/maker-deb": "^7.10.2",
"@electron-forge/maker-zip": "^7.8.3", "@electron-forge/maker-zip": "^7.10.2",
"@electron-forge/publisher-github": "^7.8.3", "@electron-forge/publisher-github": "^7.10.2",
"electron": "^38.0.0", "electron": "^39.2.4",
"jszip": "^3.10.1", "jszip": "^3.10.1",
"prettier": "^3.6.2" "prettier": "^3.6.2"
}, },
"dependencies": { "dependencies": {
"axios": "^1.11.0", "axios": "^1.13.2",
"electron-log": "^5.4.3",
"mqtt": "^5.14.1" "mqtt": "^5.14.1"
} }
} }
+951 -230
View File
File diff suppressed because it is too large Load Diff