From 1a87a2eb55135907342f9b47a4777f273d13c637 Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 23 Jan 2026 16:32:20 -0500 Subject: [PATCH 01/16] - Updated example ultrasonic-trigger.py --- haoskiosk/examples/ultrasonic-trigger.py | 245 ++++++++++++++--------- 1 file changed, 151 insertions(+), 94 deletions(-) mode change 100644 => 100755 haoskiosk/examples/ultrasonic-trigger.py diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py old mode 100644 new mode 100755 index 8dfda96..7b57903 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -1,44 +1,78 @@ +#!/usr/bin/env sh +"exec" "sudo" "$(dirname $(readlink -f $0))/venv/bin/python3" "$0" "$@" +#"exec" "$(dirname $0)/venv/bin/python3" "$0" "$@" +#Above lines used to invoke venv relative to current directory +#See: https://stackoverflow.com/questions/20095351/shebang-use-interpreter-relative-to-the-script-path + +#Below shebang line only works if call strict from the script directory +#!$(dirname $0)/venv/bin/python3 +#Below shebang line only works if already activated virtual environment +#!/usr/bin/env python3 + ################################################################################ # Add-on: HAOS Kiosk Display (haoskiosk) # File: ultrasonic-trigger.py -# Version: 1.1.1 +# Version: 1.1.0 # Copyright Jeff Kosowsky # Date: September 2025 # # Use a FTDI FT232H USB-GPIO board to monitor the output of an ultrasonic # HC-SR04 type distance sensor -# - Print out distance every second +# - Print out distance every LOOPTIME seconds # - Turn on monitor if distance < NEAR_ON_DIST for COUNT_ON_THRESH seconds # - Turn off monitor if distance > FAR_OFF_DIST for COUNT_OFF_THRESH seconds # -# Also, optionally, don't measure distance and leave display in DEFAULT_DISPLAY_STATE -# if the HA sensor HA_BINARY_SENSOR is set and evaluates to true. +# When measuring distance: +# - Take GPIO_READINGS_TO_AVERAGE and average the valid ones +# - Mark as invalid measurement if more than half of the readings are errors +# - Restart if more than INVALID_COUNT_THRESHOLD invalid measurements in a row +# +# Also, optionally, if the HA sensor HA_BINARY_SENSOR is set and evaluates to true, +# then don't measure distance and leave display in DEFAULT_DISPLAY_STATE # This can be used to make the auto on/off depend on the state of a sensor in HA. # +# # NOTES: # - Requires adding the following Python libraries: pyftdi, requests # Probably best to install in venv so it persists reboots # - Should run as root (e.g., 'sudo') # ################################################################################ +# pylint: disable=line-too-long +# pylint: disable=invalid-name +# pylint: disable=too-many-instance-attributes +# pylint: disable=broad-except +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines +################################################################################ +import logging +import os import sys import time +from datetime import datetime, timedelta import requests -from pyftdi.gpio import GpioController +from pyftdi.gpio import GpioController # type: ignore[import-untyped] from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -from datetime import datetime -import logging + +#Relaunch 'unbuffered' if not already unbuffered so that you can pipe output real-time if desired +if os.environ.get('PYTHONUNBUFFERED') != '1': + os.environ['PYTHONUNBUFFERED'] = '1' + os.execvp(sys.executable, [sys.executable] + sys.argv) # Suppress urllib3 retry warnings logging.getLogger("urllib3").setLevel(logging.ERROR) logging.basicConfig( stream=sys.stdout, -# level=logging.DEBUG, level=logging.INFO, - format="[%(asctime)s] %(levelname)s: [%(filename)s] %(message)s", +# level=logging.DEBUG, + format='%(asctime)s [%(funcName)s] %(levelname)s: %(message)s', datefmt="%H:%M:%S" ) @@ -49,15 +83,19 @@ TRIG_PIN = 0 # AD0 - Output ECHO_PIN = 1 # AD1 - Input GPIO_READINGS_TO_AVERAGE = 5 # Number of distance readings to average -WAIT_TIMEOUT = 0.05 # Timeout for wait_for_pin (seconds) +WAIT_TIMEOUT = 0.05 # Timeout for wait_for_pin (seconds) (this is conservative) # Note HC-SR04 pulls pin low after 38ms (which with speed of sound 343m/s is equivalent to ~6.5m each way) - # HA general variables HA_PORT = 8123 HA_BEARER_TOKEN = None # Needed if using HA_BINARY_SENSOR + HA_BINARY_SENSOR=None # Optional binary sensor to determine whether to measure distance and turn on/off display +HA_BINARY_SENSOR_FRIENDLY_NAME=None #Optional Friendly Name for binary sensor +if HA_BINARY_SENSOR_FRIENDLY_NAME is None and HA_BINARY_SENSOR is not None: + #Get string after last '.', replace '_' with space, capitalize words + HA_BINARY_SENSOR_FRIENDLY_NAME = HA_BINARY_SENSOR.rsplit('.', 1)[-1].replace('_', ' ').title() -DEFAULT_DISPLAY_STATE=True # Default display state if HA_BINARY_SENSOR is 'True' (False=off; True=on) +DEFAULT_DISPLAY_STATE=True # Default display state if HA_BINARY_SENSOR is 'True' (False=off; True=on) # Configure REST API REST_PORT = 8080 @@ -67,8 +105,12 @@ LOOP_TIME = 1 # Target loop time (seconds) - i.e., target time between distance measurements NEAR_ON_DIST = 150 # Near distance threshold (in cm) before turning display on FAR_OFF_DIST = 200 # Far distance threshold (in cm) before turning display off -COUNT_ON_THRESH = 3 # Number of 'near' distance measurements before turning on -COUNT_OFF_THRESH = 5 # Number of 'far' distance measurements before turning off +COUNT_ON_THRESH = 2 # Number of 'near' distance measurements before turning on +COUNT_OFF_THRESH = 4 # Number of 'far' distance measurements before turning off + + +INVALID_COUNT_THRESHOLD= 10 # Number of consecutive invalid measurements before restarting +HTTP_TIMEOUT = 3 # Timeout for HTTP get and posts ################################################################################ ### Ultrasonic distance sensing @@ -81,34 +123,39 @@ try: gpio.configure('ftdi://ftdi:232h/1', direction=TRIG_MASK) # TRIG = output, ECHO = input except Exception as e: - logging.error(f"[ultrasonic_trigger] Error: GPIO init failed") - print("Exiting due to GPIO initialization failure") + logging.error("Error: Ultrasonic trigger GPIO init failed") + print("Exiting due to Ultrasonic GPIO initialization failure") sys.exit(1) -def send_trigger_pulse(): +def send_trigger_pulse()-> bool: + """Send ultrasonic trigger pulse""" try: gpio.write(0) time.sleep(0.000002) # 2 µs gpio.write(TRIG_MASK) # Set TRIG high - time.sleep(0.00001) # 10 µs pulse + time.sleep(0.00001) # 10 us pulse gpio.write(0) return True except Exception as e: - logging.debug(f"[send_trigger_pulse] Error: GPIO write failed") + logging.debug("GPIO write failed (%s)", e) return False -def wait_for_pin(mask, level, timeout=WAIT_TIMEOUT): +def wait_for_pin(echo_mask: int, echo_level: bool, timeout: float=WAIT_TIMEOUT) -> int | None: + """Wait for pin""" + timeout_ns = int(timeout * 1e9) + start_ns = time.monotonic_ns() try: - start = time.monotonic() - while time.monotonic() - start < timeout: - if bool(gpio.read() & mask) == level: - return time.monotonic_ns() + while (time_ns := time.monotonic_ns()) - start_ns < timeout_ns: + if bool(gpio.read() & echo_mask) == echo_level: + return time_ns return None except Exception as e: - logging.debug(f"[wait_for_pin] Error: GPIO read failed") + logging.debug("Error: GPIO read failed (%s)", e) return None -def measure_distance(): +invalid_count = 0 # Number of consecutive invalid measurements +def measure_distance() -> float | None: + """Measure distance""" distances = [] errors = 0 for _ in range(GPIO_READINGS_TO_AVERAGE): @@ -118,15 +165,13 @@ def measure_distance(): start_time = wait_for_pin(ECHO_MASK, True) if start_time is None: - if logging.getLogger().isEnabledFor(logging.DEBUG): - print("Timeout waiting for ECHO to go HIGH") + logging.debug("Timeout waiting for ECHO to go HIGH") errors += 1 continue end_time = wait_for_pin(ECHO_MASK, False) if end_time is None: - if logging.getLogger().isEnabledFor(logging.DEBUG): - print("Timeout waiting for ECHO to go LOW") + logging.debug("Timeout waiting for ECHO to go LOW") errors += 1 continue @@ -138,8 +183,18 @@ def measure_distance(): errors += 1 time.sleep(0.01) # Small delay between readings to avoid sensor overload + global invalid_count if errors >= (GPIO_READINGS_TO_AVERAGE / 2): + invalid_count +=1 + if invalid_count > INVALID_COUNT_THRESHOLD: + logging.error("Error: Too many invalid measurements (%d), restarting...", INVALID_COUNT_THRESHOLD) + try: + gpio.close() + except Exception: + pass + os.execv(sys.executable, [sys.executable] + sys.argv) # Restart... return None + invalid_count = 0 # Reset invalid counter return sum(distances) / len(distances) if distances else None ################################################################################ @@ -151,59 +206,44 @@ def measure_distance(): session.mount("http://", HTTPAdapter(max_retries=retries)) def display_state() -> bool: + """Return display state""" url = f"http://localhost:{REST_PORT}/is_display_on" try: response = session.get( url, - headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"} + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"}, + timeout = HTTP_TIMEOUT, ) response.raise_for_status() data = response.json() if not data.get("success", False): - logging.debug(f"[display_state] Error: Failed to get state") + logging.error("Error: Failed to get display state") return False return data["display_on"] except (requests.RequestException, ValueError): - logging.debug(f"[display_state] Error: Request failed") + logging.debug("Error: Request failed") return False -def display_state2() -> bool: #Alternative - uses and parses 'xset -q' command - url = f"http://localhost:{REST_PORT}/xset" - try: - response = session.post( - url, - headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"}, - json={"args": "-q"} - ) - response.raise_for_status() - data = response.json() - if not data.get("success", False) or not data.get("result", {}).get("success", False): - logging.debug(f"[display_state2] Error: Failed to get state") - return False - stdout_text = data["result"].get("stdout", "") - return "Monitor is On" in stdout_text - except (requests.RequestException, ValueError): - logging.debug(f"[display_state2] Error: Request failed") - return False - -def display_state_print(): +def display_state_print() -> None: + """Print display state""" global display try: - new_display = display_state() - if new_display is True: - display = True - print('Display is ON') - elif new_display is False: - print('Display is OFF') + display = display_state() + if display is True: + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is ON") + else: + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is OFF") except (requests.RequestException, ValueError): print('Display is INVALID') def display_on() -> bool: + """Turn display on""" url = f"http://localhost:{REST_PORT}/display_on" try: response = session.post( url, - headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"} + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"}, + timeout = HTTP_TIMEOUT, ) response.raise_for_status() data = response.json() @@ -213,20 +253,14 @@ def display_on() -> bool: except (requests.RequestException, ValueError): return False -def display_on_print(): - if display_on(): - print("***Turning display ON***") - global display - display = True - else: - print("FAILED to turn display ON") - def display_off() -> bool: + """Turn display off""" url = f"http://localhost:{REST_PORT}/display_off" try: response = session.post( url, - headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"} + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"}, + timeout = HTTP_TIMEOUT, ) response.raise_for_status() data = response.json() @@ -236,40 +270,66 @@ def display_off() -> bool: except (requests.RequestException, ValueError): return False -def display_off_print(): +last_display_time = datetime.now() +def display_on_print() -> None: + """Turn on display and show duration since last on""" + global last_display_time + old_display_time = last_display_time + last_display_time = datetime.now() + display_time_diff = last_display_time - old_display_time + display_time_diff = display_time_diff - timedelta(microseconds=display_time_diff.microseconds) + + if display_on(): + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display ON*** (Duration: {display_time_diff})") + global display + display = True + else: + logging.error("Error: FAILED to turn display ON") + +def display_off_print() ->None: + """Turn off display and show duration since last off""" + global last_display_time + old_display_time = last_display_time + last_display_time = datetime.now() + display_time_diff = last_display_time - old_display_time + display_time_diff = display_time_diff - timedelta(microseconds=display_time_diff.microseconds) + if display_off(): - print("***Turning display OFF***") + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display OFF*** (Duration: {display_time_diff})") global display display = False else: - print("FAILED to turn display OFF") + logging.error("Error: FAILED to turn display OFF") -def is_binary_sensor() -> bool: - if HA_BINARY_SENSOR is None: return None - url = f"http://localhost:{HA_PORT}/api/states/{HA_BINARY_SENSOR}" +def ha_binary_sensor_state(sensor: str) -> bool | None: + """Show state of binary sensor used to turn/off ultrasonic-governed display mechanism""" + if sensor is None: + return None + url = f"http://localhost:{HA_PORT}/api/states/{sensor}" try: response = session.get( url, - headers={"Authorization": f"Bearer {HA_BEARER_TOKEN}"} + headers={"Authorization": f"Bearer {HA_BEARER_TOKEN}"}, + timeout = HTTP_TIMEOUT, ) response.raise_for_status() data = response.json() state = data.get("state") if state not in ("on", "off"): - logging.debug(f"[is_binary_sensor] Unexpected state value: {state}") - return False + logging.debug("Unexpected state value: %s", state) + return None return state == "on" except (requests.RequestException, ValueError): - logging.debug(f"[is_binary_sensor] Error: Request failed") - return False + logging.error("Error: Request failed") + return None ################################################################################ ### Main loop display = False -loop_num = -1; +loop_num = -1 count = 0 binary_sensor_state = None try: @@ -279,14 +339,14 @@ def is_binary_sensor() -> bool: if not loop_num % 60: # HA_BINARY_SENSOR state once a minute # Also, update display state in case gets out of sync old_binary_sensor_state = binary_sensor_state - binary_sensor_state = is_binary_sensor(); - if binary_sensor_state is not None and binary_sensor_state != old_binary_sensor_state: - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] {HA_BINARY_SENSOR}={binary_sensor_state}") - if binary_sensor_state: + binary_sensor_state = ha_binary_sensor_state(HA_BINARY_SENSOR) + if binary_sensor_state is not None and binary_sensor_state != old_binary_sensor_state: # Status of binary_sensor_state changed + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] '{HA_BINARY_SENSOR_FRIENDLY_NAME}' = {binary_sensor_state}") + if binary_sensor_state: # Binary sensor turned on so set to default state if DEFAULT_DISPLAY_STATE: - display_on_print() # Turn on display + display_on_print() # Default true else: - display_off_print() # Turn off display + display_off_print() # Default false if not binary_sensor_state: display_state_print() # Set and show display state every 60 seconds @@ -299,14 +359,12 @@ def is_binary_sensor() -> bool: distance_ft = distance / 30.48 print(f"Distance: {distance_ft:.2f} ft ({int(distance)} cm)") if distance < NEAR_ON_DIST: - if count < 0: - count=0 + count = max(count, 0) count += 1 if display is False and count >= COUNT_ON_THRESH: display_on_print() # Turn ON display elif distance > FAR_OFF_DIST: - if count > 0: - count=0 + count = min(count, 0) count -= 1 if display is True and count <= -COUNT_OFF_THRESH: display_off_print() # Turn OFF display @@ -315,8 +373,7 @@ def is_binary_sensor() -> bool: loop_duration = time.monotonic() - loop_start sleep_time = max(0, LOOP_TIME - loop_duration) - if logging.getLogger().isEnabledFor(logging.DEBUG): - logging.debug(f"Sleeping for {sleep_time:.3f} seconds") + logging.debug("Sleeping for %.3f seconds", sleep_time) if sleep_time > 0: time.sleep(sleep_time) @@ -324,7 +381,7 @@ def is_binary_sensor() -> bool: try: gpio.close() except Exception as e: - logging.error(f"[main] Error: GPIO close failed") + logging.error("Error: GPIO close failed") print("\nExiting.") # vim: set filetype=python : From f7e407a62538b9917f94bb6701d501e390506fcc Mon Sep 17 00:00:00 2001 From: puterboy Date: Sun, 25 Jan 2026 00:02:47 -0500 Subject: [PATCH 02/16] Added count=0 initialization --- haoskiosk/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index 9ea6ab0..bd58a06 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -577,6 +577,7 @@ if [ "$DEBUG_MODE" != true ]; then $BROWSER ${BROWSER_FLAGS:+$BROWSER_FLAGS} "$HA_URL/$HA_DASHBOARD" & bashio::log.info "Launching $BROWSER browser(PID=$!): $HA_URL/$HA_DASHBOARD" + count=0 while true; do # Wait for all browser processes to exit if pgrep -f -- "^$BROWSER " > /dev/null 2>&1; then count=0 From 9cba1ebdea8d6d0828724b893a7950f8c0c5e22f Mon Sep 17 00:00:00 2001 From: puterboy Date: Sun, 25 Jan 2026 12:19:37 -0500 Subject: [PATCH 03/16] Remove toggle_keyboard.py --- haoskiosk/toggle_keyboard.py | 39 ------------------------------------ 1 file changed, 39 deletions(-) delete mode 100644 haoskiosk/toggle_keyboard.py diff --git a/haoskiosk/toggle_keyboard.py b/haoskiosk/toggle_keyboard.py deleted file mode 100644 index fe294ae..0000000 --- a/haoskiosk/toggle_keyboard.py +++ /dev/null @@ -1,39 +0,0 @@ -################################################################################ -# Add-on: HAOS Kiosk Display (haoskiosk) -# File: toggle_keyboard.py -# Version: 1.2.0 -# Copyright Jeff Kosowsky -# Date: January 2026 -# -# Creates a 1x1 pixel button at top right corner of screen to toggle onboard -# keyboard on/off. -# If optional parameter is true, then pixel is 'black', otherwise 'white' -################################################################################ - -import tkinter as tk -import subprocess -import sys - -def toggle_keyboard(event): - subprocess.Popen([ - "dbus-send", - "--type=method_call", - "--print-reply", - "--dest=org.onboard.Onboard", - "/org/onboard/Onboard/Keyboard", - "org.onboard.Onboard.Keyboard.ToggleVisible" - ]) - -root = tk.Tk() -root.overrideredirect(True) -root.geometry("+{}+{}".format(root.winfo_screenwidth()-1, 0)) -root.attributes("-topmost", True) - -color = "black" if len(sys.argv) > 1 and sys.argv[1].lower() == "true" else "white" - -canvas = tk.Canvas(root, width=1, height=1, highlightthickness=0, bg=color) -canvas.pack() - -canvas.bind("", toggle_keyboard) - -root.mainloop() From 7b71cfb906ce76408b22852b366be1017de145c1 Mon Sep 17 00:00:00 2001 From: puterboy Date: Sun, 25 Jan 2026 12:42:30 -0500 Subject: [PATCH 04/16] - Bumped version to 1.3.0-test - Changed logging format in rest_server.py --- haoskiosk/CHANGELOG.md | 2 +- haoskiosk/Dockerfile | 2 +- haoskiosk/README.md | 2 +- haoskiosk/config.yaml | 2 +- haoskiosk/gesture_commands.json | 2 +- haoskiosk/mouse_touch_inputs.py | 4 ++-- haoskiosk/rest_server.py | 6 +++--- haoskiosk/run.sh | 2 +- haoskiosk/userconf.lua | 2 +- haoskiosk/xorg.conf.default | 2 +- 10 files changed, 13 insertions(+), 13 deletions(-) diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index ba66d3b..31461a6 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## v1.2.0 - January 2026 +## v1.3.0 - January 2026 - Added ability to set HA theme in config.yaml - Added USB audio (`audio: true` and `usb: true` in config.yaml) Added diff --git a/haoskiosk/Dockerfile b/haoskiosk/Dockerfile index ea28d85..c550257 100644 --- a/haoskiosk/Dockerfile +++ b/haoskiosk/Dockerfile @@ -1,7 +1,7 @@ ################################################################################ # Add-on: HAOS Kiosk Display (haoskiosk) # File: Dockerfile -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 ################################################################################ diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 2f5b754..689d919 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -2,7 +2,7 @@ Display HA dashboards in kiosk mode directly on your HAOS server. -## Author: Jeff Kosowsky (version: 1.2.0, January 2026) +## Author: Jeff Kosowsky (version: 1.3.0, January 2026) ## Description diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 9fbeeb8..6a1b8d6 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.2.0" +version: "1.3.0-test1" slug: "haoskiosk" arch: diff --git a/haoskiosk/gesture_commands.json b/haoskiosk/gesture_commands.json index b338c3d..5b33149 100644 --- a/haoskiosk/gesture_commands.json +++ b/haoskiosk/gesture_commands.json @@ -1,7 +1,7 @@ # ============================================================================== # HAOS Kiosk Display — Mouse & Touch Input Engine # File: gesture_commands.json -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 # ------------------------------------------------------------------------------ diff --git a/haoskiosk/mouse_touch_inputs.py b/haoskiosk/mouse_touch_inputs.py index f4136dd..fcbc6f9 100644 --- a/haoskiosk/mouse_touch_inputs.py +++ b/haoskiosk/mouse_touch_inputs.py @@ -12,7 +12,7 @@ """------------------------------------------------------------------------------- # HAOS Kiosk Display — Mouse & Touch Input Engine # File: MouseTouchInputs -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 # @@ -369,7 +369,7 @@ from Xlib import display #type: ignore[import-untyped] #pylint: disable=import-error from Xlib.xobject.drawable import Window #type: ignore[import-untyped] #pylint: disable=import-error #------------------------------------------------------------------------------- -__version__ = "1.2.0" +__version__ = "1.3.0" __author__ = "Jeff Kosowsky" __copyright__ = "Copyright 2025 Jeff Kosowsky" #------------------------------------------------------------------------------- diff --git a/haoskiosk/rest_server.py b/haoskiosk/rest_server.py index af5be55..96d71ce 100644 --- a/haoskiosk/rest_server.py +++ b/haoskiosk/rest_server.py @@ -1,7 +1,7 @@ """------------------------------------------------------------------------------- # Add-on: HAOS Kiosk Display (haoskiosk) # File: services.py -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 @@ -59,7 +59,7 @@ from aiohttp import web #type: ignore[import-not-found] #pylint: disable=import-error #------------------------------------------------------------------------------- -__version__ = "1.2.0" +__version__ = "1.3.0" __author__ = "Jeff Kosowsky" __copyright__ = "Copyright 2025 Jeff Kosowsky" @@ -159,7 +159,7 @@ def is_valid_url(url: str) -> bool: stream = sys.stdout, level = logging.INFO, # level = logging.DEBUG, - format = "[%(asctime)s] %(levelname)s: [%(filename)s:%(lineno)d] %(message)s", + format = "[%(asctime)s] %(levelname)s: [%(filename)s:%(funcName)s] %(message)s", datefmt = "%H:%M:%S", ) logger = logging.getLogger(__name__) diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index bd58a06..d8d2851 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -3,7 +3,7 @@ ################################################################################ # Add-on: HAOS Kiosk Display (haoskiosk) # File: run.sh -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 # diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index d604055..d09a7aa 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -1,7 +1,7 @@ --[=[ Add-on: HAOS Kiosk Display (haoskiosk) File: userconf.lua for HA minimal browser run on server -Version: 1.2.0 +Version: 1.3.0 Copyright Jeff Kosowsky Date: January 2026 diff --git a/haoskiosk/xorg.conf.default b/haoskiosk/xorg.conf.default index 32a40c2..ef9b2d1 100644 --- a/haoskiosk/xorg.conf.default +++ b/haoskiosk/xorg.conf.default @@ -1,7 +1,7 @@ ################################################################################ # Add-on: HAOS Kiosk Display (haoskiosk) # File: xorg.conf -# Version: 1.2.0 +# Version: 1.3.0 # Copyright Jeff Kosowsky # Date: January 2026 # From d16555ce8e1892d0621f2376c0651ecccb781933 Mon Sep 17 00:00:00 2001 From: puterboy Date: Sun, 25 Jan 2026 13:55:41 -0500 Subject: [PATCH 05/16] - Improved logging in ultrasonic-trigger.py example - Added exit handling (including turning display back on) in ultrasonic-trigger.py example --- haoskiosk/examples/ultrasonic-trigger.py | 66 +++++++++++++++--------- 1 file changed, 41 insertions(+), 25 deletions(-) diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py index 7b57903..8d337f9 100755 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -50,10 +50,13 @@ # pylint: disable=too-many-lines ################################################################################ + import logging import os +import signal import sys import time +import types from datetime import datetime, timedelta import requests from pyftdi.gpio import GpioController # type: ignore[import-untyped] @@ -75,6 +78,7 @@ format='%(asctime)s [%(funcName)s] %(levelname)s: %(message)s', datefmt="%H:%M:%S" ) +logger = logging.getLogger(__name__) ################################################################################ ### Configurable variables @@ -123,10 +127,17 @@ try: gpio.configure('ftdi://ftdi:232h/1', direction=TRIG_MASK) # TRIG = output, ECHO = input except Exception as e: - logging.error("Error: Ultrasonic trigger GPIO init failed") - print("Exiting due to Ultrasonic GPIO initialization failure") + logger.error("Ultrasonic trigger GPIO initialization failed...exiting (%s)", e) sys.exit(1) +def handle_exit(_signum: int, _frame: types.FrameType | None) -> None: + """Exit handler""" + sys.exit(0) + +# Register signals +for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(sig, handle_exit) + def send_trigger_pulse()-> bool: """Send ultrasonic trigger pulse""" try: @@ -137,7 +148,7 @@ def send_trigger_pulse()-> bool: gpio.write(0) return True except Exception as e: - logging.debug("GPIO write failed (%s)", e) + logger.debug("GPIO write FAILED (%s)", e) return False def wait_for_pin(echo_mask: int, echo_level: bool, timeout: float=WAIT_TIMEOUT) -> int | None: @@ -150,7 +161,7 @@ def wait_for_pin(echo_mask: int, echo_level: bool, timeout: float=WAIT_TIMEOUT) return time_ns return None except Exception as e: - logging.debug("Error: GPIO read failed (%s)", e) + logger.debug("GPIO read FAILED (%s)", e) return None invalid_count = 0 # Number of consecutive invalid measurements @@ -165,13 +176,13 @@ def measure_distance() -> float | None: start_time = wait_for_pin(ECHO_MASK, True) if start_time is None: - logging.debug("Timeout waiting for ECHO to go HIGH") + logger.debug("Timeout waiting for ECHO to go HIGH") errors += 1 continue end_time = wait_for_pin(ECHO_MASK, False) if end_time is None: - logging.debug("Timeout waiting for ECHO to go LOW") + logger.debug("Timeout waiting for ECHO to go LOW") errors += 1 continue @@ -187,7 +198,7 @@ def measure_distance() -> float | None: if errors >= (GPIO_READINGS_TO_AVERAGE / 2): invalid_count +=1 if invalid_count > INVALID_COUNT_THRESHOLD: - logging.error("Error: Too many invalid measurements (%d), restarting...", INVALID_COUNT_THRESHOLD) + logger.error("Too many invalid measurements (%d), restarting...", INVALID_COUNT_THRESHOLD) try: gpio.close() except Exception: @@ -217,11 +228,11 @@ def display_state() -> bool: response.raise_for_status() data = response.json() if not data.get("success", False): - logging.error("Error: Failed to get display state") + logger.error("Failed to get display state") return False - return data["display_on"] - except (requests.RequestException, ValueError): - logging.debug("Error: Request failed") + return data["display_on"] is True + except (requests.RequestException, ValueError) as e: + logger.error("HTTPRequest failed (%s)", e) return False def display_state_print() -> None: @@ -233,8 +244,8 @@ def display_state_print() -> None: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is ON") else: print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is OFF") - except (requests.RequestException, ValueError): - print('Display is INVALID') + except (requests.RequestException, ValueError) as e: + logger.error("Display is INVALID (%s)", e) def display_on() -> bool: """Turn display on""" @@ -248,9 +259,11 @@ def display_on() -> bool: response.raise_for_status() data = response.json() if not data.get("success", False): + logger.error("Failed to get display state") return False return True - except (requests.RequestException, ValueError): + except (requests.RequestException, ValueError) as e: + logger.error("HTTPRequest failed (%s)", e) return False def display_off() -> bool: @@ -267,7 +280,8 @@ def display_off() -> bool: if not data.get("success", False): return False return True - except (requests.RequestException, ValueError): + except (requests.RequestException, ValueError) as e: + logger.error("HTTPRequest failed (%s)", e) return False last_display_time = datetime.now() @@ -284,7 +298,7 @@ def display_on_print() -> None: global display display = True else: - logging.error("Error: FAILED to turn display ON") + logger.error("FAILED to turn display ON") def display_off_print() ->None: """Turn off display and show duration since last off""" @@ -299,7 +313,7 @@ def display_off_print() ->None: global display display = False else: - logging.error("Error: FAILED to turn display OFF") + logger.error("FAILED to turn display OFF") def ha_binary_sensor_state(sensor: str) -> bool | None: """Show state of binary sensor used to turn/off ultrasonic-governed display mechanism""" @@ -313,16 +327,16 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: timeout = HTTP_TIMEOUT, ) response.raise_for_status() - data = response.json() + data: dict[str, str] = response.json() state = data.get("state") if state not in ("on", "off"): - logging.debug("Unexpected state value: %s", state) + logger.debug("Unexpected state value: %s", state) return None return state == "on" - except (requests.RequestException, ValueError): - logging.error("Error: Request failed") + except (requests.RequestException, ValueError) as e: + logger.error("HTTP Request failed (%s)", e) return None ################################################################################ @@ -373,16 +387,18 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: loop_duration = time.monotonic() - loop_start sleep_time = max(0, LOOP_TIME - loop_duration) - logging.debug("Sleeping for %.3f seconds", sleep_time) + logger.debug("Sleeping for %.3f seconds", sleep_time) if sleep_time > 0: time.sleep(sleep_time) -except KeyboardInterrupt: +finally: try: + if display is False: + display_on_print() # Turn display back on... gpio.close() except Exception as e: - logging.error("Error: GPIO close failed") - print("\nExiting.") + logger.error("Error: GPIO close failed (%s)", e) + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Exiting...") # vim: set filetype=python : # Local Variables: From f82571040dd3bf30d9bd2760d3d6ba6276198a39 Mon Sep 17 00:00:00 2001 From: puterboy Date: Thu, 29 Jan 2026 11:22:46 -0500 Subject: [PATCH 06/16] - Multiple changes to rest_server.py, userconf.lua, README etc. --- README.md | 368 ++++++++++++++++++----- haoskiosk/CHANGELOG.md | 19 +- haoskiosk/Dockerfile | 3 +- haoskiosk/README.md | 368 ++++++++++++++++++----- haoskiosk/config.yaml | 19 +- haoskiosk/examples/ultrasonic-trigger.py | 318 ++++++++++++++------ haoskiosk/gesture_commands.json | 2 +- haoskiosk/mouse_touch_inputs.py | 93 +++--- haoskiosk/rest_server.py | 273 +++++++++++++++-- haoskiosk/run.sh | 10 +- haoskiosk/userconf.lua | 49 +-- haoskiosk/xorg.conf.default | 2 +- 12 files changed, 1187 insertions(+), 337 deletions(-) diff --git a/README.md b/README.md index 2f5b754..542c5be 100644 --- a/README.md +++ b/README.md @@ -2,47 +2,45 @@ Display HA dashboards in kiosk mode directly on your HAOS server. -## Author: Jeff Kosowsky (version: 1.2.0, January 2026) +## Author: Jeff Kosowsky (version: 1.3.0, February 2026) ## Description Launches X-Windows on local HAOS server followed by OpenBox window manager -and Luakit browser.\ -Standard mouse and keyboard interactions should work automatically. -Supports touchscreens (including onscreen keyboard) and screen rotation. -Includes REST API that can be used to control the display state and to send -new URLs (e.g., dashboards) to the kiosk browser. +and Luakit browser starting with your configured default Home Assistant +dashboard. -You can press `ctl-R` at any time to refresh ( reload) the browser./ +- Standard mouse, touchscreen, and keyboard interactions should work + automatically as well as audio +- Supports touchscreens gestures, screen rotation, and onscreen keyboard +- Includes REST API that can be used to control the display state and to + send new URLs (e.g., dashboards) to the kiosk browser. + +You can press `ctl-R` at any time to refresh ( reload) the browser. \ Alternatively, you can right click (or long press touchscreen) to access browser menu that includes options for page `Back`, `Forward`, `Stop`, and `Reload`. **NOTE:** You must enter your HA username and password in the -*Configuration* tab for add-on to start. - -**NOTE:** The add-on requires a valid, connected display in order to start. -\ -If display does not show up, try rebooting and restarting the addon with -the display attached +*Configuration* tab for the Add-on to start. -**Note:** Luakit browser is launched in kiosk-like (*passthrough*) mode.\ -To enter *normal* mode (similar to command mode in `vi`), press -`ctl-alt-esc`.\ -You can then return to *passthrough* mode by pressing `ctl-Z` or enter -*insert* mode by pressing `i`.\ -See luakit documentation for available commands.\ -In general, you want to stay in `passthrough` mode. +**NOTE:** The Add-on requires a valid, connected display in order to +start.\ +If your display does not show up, try rebooting and restarting the Add-on +with the display attached **NOTE:** Should support any standard mouse, touchscreen, keypad and -touchpad so long as their /dev/input/eventN number is less than 25. +touchpad so long as its `/dev/input/eventN` number is less than 25. -**NOTE:** If not working, please first check the bug reports (open and +**NOTE:** If you encounter issues with the Add-on, please first check the +HAOSKiosk github +[issues page](https://github.com/puterboy/HAOS-kiosk/issues) (open and closed), then try the testing branch (add the following url to the -repository: https://github.com/puterboy/HAOS-kiosk#testing). If still no -solution, file an issue on github -[bug report](https://github.com/puterboy/HAOS-kiosk/issues) and include -full details of your setup and what you did along with a complete log. +repository: https://github.com/puterboy/HAOS-kiosk#testing). If still +please file an +[issue on github](https://github.com/puterboy/HAOS-kiosk/issues) and +\*\*include full details of your setup (including computer hardware and +display type details)and what you did along with a complete log. ### If you appreciate my efforts: @@ -50,6 +48,43 @@ full details of your setup and what you did along with a complete log. ______________________________________________________________________ +## Installation + +1. Click the **ADD ADD-ON REPOSITORY** button below. + + [![Open your Home Assistant instance and show the add Add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fputerboy%2FHAOS-kiosk) + + - Click **Add → Close** (You might need to enter the **internal IP + address** of your Home Assistant instance first) *or* go to the + **Add-on store**. + - Click **⋮ → Repositories** + - Fill in `https://github.com/puterboy/HAOS-kiosk` + - Click **Add → Close** + +2. Click on the Add-on, press **Install** and wait until the Add-on is + installed. + +3. You must enter your HA username and password in the **Configuration** + tab. + +4. Press **Start** to run the Add-on. + +**If you are having trouble installing the add-on or getting displays and +touchscreens working, please see the github issues page +(https://github.com/puterboy/HAOS-kiosk/issues)as many common issues have +already been addressed and resolved** + +### Notes + +- If screen is not working on an RPi3, try adding the following lines to + the `[pi3]` section of your `config.txt` on the boot partition: + ``` + dtoverlay=vc4-fkms-v3d + max_framebuffers=2 + ``` + +______________________________________________________________________ + ## Configuration Options ### HA Username [required] @@ -206,8 +241,9 @@ Note for security REST server only listens on localhost (127.0.0.1) ### REST Bearer Token -Optional authorization token for REST API. (Default: "") If set, then add -line `-H "Authorization: Bearer "` to REST API calls. +Optional authorization token for REST API. (Default: "") If set, then you +must add line `-H "Authorization: Bearer "` to REST API +calls. ### Debug @@ -226,18 +262,31 @@ examples, and default gestures. ### Command Whitelist Regex -Regex (Python) of shell command that can be used in creating gesture +Regex (Python) of shell command that can be used in creating gesture action commands or when running the `run_command` and `run_commands` REST APIs. If left blank, then all commands are allowed except for those blacklisted as dangerous (otherwise, whitelist overrides internal blacklist). +The pre-defined command blacklist includes commands like: + +``` + python + ash, bash, sh, su + env, exec + kill, killall, pkill + cp, chmod, chown, dd, ln, mv, rm, tar + mount, umount + curl, nc, wget + find, xargs" +``` + Note that if you want to truly allow *all* commands, then use the wildcard `.*` but beware that it is DANGEROUS. If you want to disallow all commands set the regex to `^$`. Note that regardless of setting only commands found in `/bin`, `/usr/bin`, -and `/usr/local/bin` of the HAOSKiosk add-on container are allowed. +and `/usr/local/bin` of the HAOSKiosk Add-on container are allowed. ______________________________________________________________________ @@ -246,10 +295,14 @@ ______________________________________________________________________ ### launch_url {"url": "\"} Launch the specified 'url' in the kiosk display. Overwrites current active -tab. +tab. If no url supplied, use HA_URL/HA_DASHBOARD as default url. Usage: -`curl -X POST http://localhost:/launch_url -H "Content-Type: application/json" -d '{"url": ""}'` + +``` +curl -X POST http://localhost:/launch_url +curl -X POST http://localhost:/launch_url -H "Content-Type: application/json" -d '{"url": ""}' +``` ### refresh_browser @@ -277,7 +330,7 @@ Usage: ``` curl -X POST http://localhost:/display_on -curl -X POST http://localhost:8080/display_on -H "Content-Type: application/json" -d '{"timeout": } +curl -X POST http://localhost:8080/display_on -H "Content-Type: application/json" -d '{"timeout": }' ``` ### display_off @@ -291,24 +344,20 @@ Usage: ### xset Run `xset ` to get/set display information. In particular, use `-q` -to get display information. +to get display information. Can only be run from localhost unless +REST_BEARER_TOKEN set and used. Usage: `curl -X POST http://localhost:/xset -H "Content-Type: application/json" -d '{"args": ""}'` -### current_processes - -Return number of currently running concurrent processes out of max allowed - -Usage: `curl -X GET http://localhost:8080/current_processes` - ### run_command {"cmd": "\"} Run `command` in the HAOSKiosk Docker container where `cmd_timeout` is an optional timeout in seconds. -Only allowed if `Allow User Commands` option is set to true. +Commands are subject to blacklist/whitelist rules detailed above. Can only +be run from localhost unless REST_BEARER_TOKEN set and used. Usage: @@ -319,7 +368,8 @@ Usage: Run multiple commands in the HAOSKiosk Docker container where `cmd_timeout` is an optional timeout in seconds. -Only allowed if `Allow User Commands` option is set to true. +Commands are subject to blacklist/whitelist rules detailed above. Can only +be run from localhost unless REST_BEARER_TOKEN set and used. Usage: @@ -348,7 +398,57 @@ You can format the stdout (and similarly stderr) by piping the output to: In the case of `run_commands`, pipe the output to: `jq -r '.results[]?.stdout'` -______________________________________________________________________ +### current_processes + +Return number of currently running concurrent processes out of max allowed. + +Usage: `curl -X GET http://localhost:8080/current_processes` + +### disable_inputs + +Disable keyboard and pointer (e.g., mouse, touch) inputs. Can only be run +from localhost unless REST_BEARER_TOKEN set and used. + +Usage: + +`curl -X POST http://localhost:/disable_inputs` + +### enable_inputs + +(Re)enable keyboard and pointer (e.g., mouse, touch) inputs. Can only be +run from localhost unless REST_BEARER_TOKEN set and used. + +Usage: + +`curl -X POST http://localhost:/enable_inputs` + +### mute_audio + +Mute audio output for default audio sink. Returns final mute state. + +Usage: + +`curl -X POST http://localhost:/mute_audio` + +### unmute_audio {"volume": "\"} + +Unmute audio output for default audio sink. Optionally, set volume (integer +between 0 and 150). Returns final volume and mute state + +Usage: + +``` +curl -X POST http://localhost:/unmute_audio +curl -X POST http://localhost:8080/unmute_audio -H "Content-Type: application/json" -d '{"volume": }' +``` + +### toggle_audio + +Toggle mute for default audio sink. Returns final mute state. + +Usage: + +`curl -X POST http://localhost:/toggle_audio` #### HA REST Command Syntax @@ -386,11 +486,6 @@ rest_command: content_type: "application/json" payload: "{}" - haoskiosk_current_processes: - url: "http://localhost:8080/current_processes" - method: GET - content_type: "application/json" - haoskiosk_xset: url: "http://localhost:8080/xset" method: POST @@ -408,6 +503,39 @@ rest_command: method: POST content_type: "application/json" payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmds": {{ cmds | tojson }}, "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmds": {{ cmds | tojson }}}{% endif %}' + + haoskiosk_current_processes: + url: "http://localhost:8080/current_processes" + method: GET + content_type: "application/json" + + haoskiosk_disable_inputs: + url: "http://localhost:8080/disable_inputs" + method: POST + content_type: "application/json" + payload: "{}" + + haoskiosk_enable_inputs: + url: "http://localhost:8080/enable_inputs" + method: POST + content_type: "application/json" + payload: "{}" + + haoskiosk_mute_audio: + url: "http://localhost:8080/mute_audio" + method: POST + content_type: "application/json" + + haoskiosk_unmute_audio: + url: "http://localhost:8080/unmute_audio" + method: POST + content_type: "application/json" + payload: '{"volume": "{{ volume }}"}' + + haoskiosk_toggle_audio: + url: "http://localhost:8080/toggle_audio" + method: POST + content_type: "application/json" ``` Note if optional \`REST_BEARER_TOKEN~ is set, then add the following two @@ -425,6 +553,7 @@ The rest commands can then be referenced from automation actions as: ``` actions: + - action: rest_command.haoskiosk_launch_url - action: rest_command.haoskiosk_launch_url data: url: "https://homeassistant.local/my_dashboard" @@ -440,8 +569,6 @@ actions: - action: rest_command.haoskiosk_display_off - - action: rest_command.haoskiosk_current_processes - - action: rest_command.haoskiosk_xset data: args: "" @@ -454,11 +581,25 @@ actions: - action: rest_command.haoskiosk_run_commands data: cmds: - -- "" + - "" - "" ... cmd_timeout: + + - action: rest_command.haoskiosk_current_processes + + - action: rest_command.haoskiosk_disable_inputs + + - action: rest_command.haoskiosk_enable_inputs + + - action: rest_command.haoskiosk_mute_audio + + - action: rest_command.haoskiosk_unmute_audio + - action: rest_command.haoskiosk_unmute_audio + data: + volume: 100 + + - action: rest_command.haoskiosk_toggle_audio ``` ### REST API Use Cases @@ -485,7 +626,7 @@ actions: ______________________________________________________________________ -### GESTURE COMMANDS +## Gesture Commands Each Gesture Command is a JSON-like key-value pair where the key is a valid *Gesture String* corresponding to a specific sequence of button clicks or @@ -495,7 +636,7 @@ set of one or more commands to execute when the gesture is recognized. The formats of the Gesture Strings and Action Commands are precisely defined, so if they fail to load check your log for error messages. -#### Gesture String Keys +### Gesture String Keys Each Gesture String key is of form: @@ -531,7 +672,7 @@ of the gesture `Long Tap`). Note the names may be device-specific (e.g., Click for Mouse, Tap for Touch) -##### Additional notes regarding gesture naming: +#### Additional notes regarding gesture naming: - `ANY` is a wildcard matching any gesture - Corners are named: CORNER_TOPLEFT, CORNER_TOPRIGHT, CORNER_BOTLEFT, @@ -576,9 +717,9 @@ always enter keys from particular to more general when using wildcards 1-Touch_2-Long (Long gestures must be single contact) ``` -#### Action Command Values +### Action Commands -Action command values may be expressed in one of three forms: +Action commands may be expressed in one of three forms: 1. **Single command string** e.g., `"ls -a -l"` Note that an empty string acts as a No-Op -- i.e., it will be ignored and can be used with @@ -587,14 +728,13 @@ Action command values may be expressed in one of three forms: 2. **List of commands** - Each command may be either: - A string: `"echo hello"` - - An argv-style list: `["ls", "-a", "-l"]` + - An argv-style list: `["ls", "-a", "-l"]` Example `["echo hello", ["ls", "-a", "-l"]]` Note that the commands themselves can either be shell commands or -kiosk-specific internal commands that begin with the prefix 'kiosk.' - -Examples include: +kiosk-specific internal commands (see below) that begin with the prefix +'kiosk.' 3. **Command dictionary** with required key `"cmds":` and optional keys: `"msg":` and `"timeout"` @@ -606,7 +746,19 @@ Examples: {"cmds": ["echo hello", ["ls", "-al"]], "msg": "echo hello and list all files", "timeout": 5} ``` -#### Defaults & Examples +#### Kiosk-specific internal commands + +- **kiosk.back**: Go back in browser history +- **kiosk.forward**: Go forward in browser history +- **kiosk.refresh_browser**: Reload current page +- **kiosk.launch_url **: Launch in existing tab/window.\ + If no given, use HA_URL/HA_DASHBOARD as default +- **kiosk.display_on **: Turn on display with optional timeout +- **kiosk.display_off**: Turn off display immediately +- **kiosk.toggle_keyboard**: Toggle onscreen keyboard +- **kiosk.toggle_audio**: Toggle mute state of default audio sink + +### Defaults Gesture Command Bindings The following gestures are included by default (but can be removed by clicking on the `X` next to them): @@ -614,59 +766,117 @@ clicking on the `X` next to them): - **Single Tap or Click in Top Right Corner**: *Toggle on-screen keyboard* ``` -"1_ANY_1_CORNER_TOPRIGHT": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"1_ANY_1_CORNER_TOPRIGHT": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **Left Triple Mouse Click**: *Toggle on-screen keyboard* ``` -"[Left]_MOUSE_3_CLICK": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"[Left]_MOUSE_3_CLICK": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **3-Finger Single Tap**: *Toggle on-screen keyboard* ``` -"3_TOUCH_1_TAP": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"3_TOUCH_1_TAP": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **3-Finger Double Tap**: *Refresh Browser* ``` -"3_TOUCH_2_TAP": {"cmds": [["xdotool", "key", "--clearmodifiers ctrl+r"]], "msg": "Refresh Browser"} +"3_TOUCH_2_TAP": {"cmds": "kiosk.refresh_browser", "msg": "Refresh Browser"} ``` -- **3-Finger Triple Tap**: *Restore Default HA dashboard - (HA_URL/HA_Dashboard)* +- **3-Finger Triple Tap**: *Toggle audio mute* ``` -"3_TOUCH_3_TAP": {"cmds": "luakit \"$HA_URL/$HA_DASHBOARD\"", "msg": "Restore default dashboard"} +"3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' ``` - **3-Finger Left Swipe**: *Go forward one element in browser history* ``` -"3_TOUCH_1_SWIPE_LEFT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Right"]], "msg": "Go forward in the history browser"} +"3_TOUCH_1_SWIPE_LEFT": {"cmds": "kiosk.forward", "msg": "Go forward in the history browser"} ``` - **3-Finger Right Swipe**: *Go back one element in browser history* ``` -"3_TOUCH_1_SWIPE_RIGHT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Left"]], "msg": "Go back in the history browser"} +"3_TOUCH_1_SWIPE_RIGHT": {"cmds": "kiosk.back", "msg": "Go back in the history browser"} +``` + +- **2-Finger Triple Tap**: *Restore Default HA dashboard + (HA_URL/HA_Dashboard)* + +``` +"2_TOUCH_3_TAP": {"cmds": "kiosk.launch_url", "msg": "Restore default dashboard: HA_URL/HA_DASHBOARD"} ``` -- **2-Finger Triple Tap**: *Open Google search* +- **2-Finger Quadruple Tap**: *Open Google search* ``` -"2_TOUCH_3_TAP": {"cmds": "luakit \"www.google.com\"", "msg": "Open Google search"}' +"2_TOUCH_4_TAP": {"cmds": [["kiosk.lauch_url", "www.google.com"]], "msg": "Open Google search"}' +``` + +Note if you didn't have the built-in functions, you could have manually +implemented the above as: + +``` +"1_ANY_1_CORNER_TOPRIGHT": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"[Left]_MOUSE_3_CLICK": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"3_TOUCH_1_TAP": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"3_TOUCH_2_TAP": {"cmds": [["xdotool", "key", "--clearmodifiers ctrl+r"]], "msg": "Refresh Browser"} + +"3_TOUCH_3_TAP": {"cmds": [["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"]], "msg": "Toggle audio mute"}' + +"3_TOUCH_1_SWIPE_LEFT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Right"]], "msg": "Go forward in the history browser"} + +"3_TOUCH_1_SWIPE_RIGHT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Left"]], "msg": "Go back in the history browser"} + +"2_TOUCH_3_TAP": {"cmds": "luakit \"$HA_URL/$HA_DASHBOARD\"", "msg": "Restore default dashboard"} + +"2_TOUCH_4_TAP": {"cmds": "luakit \"www.google.com\"", "msg": "Open Google search"}' ``` ______________________________________________________________________ +## KEYBOARD SHORTCUTS + +The following fixed keyboard shortcuts are defined (but subject to change). + +- **Ctrl+r:** *Reload page* + +- **Ctrl+LeftArrow:** *Go back in the browser tab history* + +- **Ctrl+RightArrow:** *Go forward in the browser tab history* + +- **Ctrl+Alt+t:** *Open new tab* + +- **Ctrl+Alt+Shift+t:** *Close current tab* + +- **Ctrl+Alt+w:** *Open new window* + +______________________________________________________________________ + ## MISCELLANEOUS NOTES -- If screen is not working on an RPi3, try adding the following lines to - the `[pi3]` section of your `config.txt` on the boot partition: - ``` - dtoverlay=vc4-fkms-v3d - max_framebuffers=2 - ``` +#### Luakit browser + +The Luakit browser is launched in kiosk-like (*passthrough*) mode. In +general, you want to stay in `passthrough` mode to preserve the kiosk-like +experience and pass all keystrokes to the browser page (except for explicit +bindings as defined above) + +Luakit modes and commands are similar to vi + +- To enter *normal* mode (similar to command mode in `vi`), press + `ctl-alt-esc`. + +- To return to *passthrough* mode, press `ctl-Z` or alternatively, press + `i` to enter *insert* + +See [luakit documentation](https://wiki.archlinux.org/title/Luakit) for +further usage information and available commands. diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index 31461a6..446c6e8 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -1,6 +1,23 @@ # Changelog -## v1.3.0 - January 2026 +## v1.3.0 - February 2026 + +- Added `enable_inputs` and `disable_inputs` functions to REST_API to allow + locking down (and unlocking) inputs by disabling keyboard, mouse and + touch functions +- Added `mute_audio`, `unmute_audio` and `toggle_audio` functions to + REST_API to change audio state (`toggle_audio` can also be used in + gesture action commands) +- Converted default gestures in `config.yaml` to use internal + `kiosk.` handlers rather than calling shell functions +- Added short list of built-in keyboard shortcuts +- Revamped `ultrasonic-trigger.py` example and added new functionality to + enable/disable inputs, mute/unmute audio, and rotate through a list of + URLs +- Added INSTRUCTIONS section to README.md (thanks: @cvroque) +- Added more details to README. + +## v1.2.0 - January 2026 - Added ability to set HA theme in config.yaml - Added USB audio (`audio: true` and `usb: true` in config.yaml) Added diff --git a/haoskiosk/Dockerfile b/haoskiosk/Dockerfile index c550257..80623bb 100644 --- a/haoskiosk/Dockerfile +++ b/haoskiosk/Dockerfile @@ -3,7 +3,7 @@ # File: Dockerfile # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 ################################################################################ ARG BUILD_FROM @@ -33,6 +33,7 @@ RUN apk update && apk add --no-cache \ xinput \ xrandr \ xset \ + evtest \ unclutter-xfixes \ setxkbmap \ openbox \ diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 689d919..542c5be 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -2,47 +2,45 @@ Display HA dashboards in kiosk mode directly on your HAOS server. -## Author: Jeff Kosowsky (version: 1.3.0, January 2026) +## Author: Jeff Kosowsky (version: 1.3.0, February 2026) ## Description Launches X-Windows on local HAOS server followed by OpenBox window manager -and Luakit browser.\ -Standard mouse and keyboard interactions should work automatically. -Supports touchscreens (including onscreen keyboard) and screen rotation. -Includes REST API that can be used to control the display state and to send -new URLs (e.g., dashboards) to the kiosk browser. +and Luakit browser starting with your configured default Home Assistant +dashboard. -You can press `ctl-R` at any time to refresh ( reload) the browser./ +- Standard mouse, touchscreen, and keyboard interactions should work + automatically as well as audio +- Supports touchscreens gestures, screen rotation, and onscreen keyboard +- Includes REST API that can be used to control the display state and to + send new URLs (e.g., dashboards) to the kiosk browser. + +You can press `ctl-R` at any time to refresh ( reload) the browser. \ Alternatively, you can right click (or long press touchscreen) to access browser menu that includes options for page `Back`, `Forward`, `Stop`, and `Reload`. **NOTE:** You must enter your HA username and password in the -*Configuration* tab for add-on to start. - -**NOTE:** The add-on requires a valid, connected display in order to start. -\ -If display does not show up, try rebooting and restarting the addon with -the display attached +*Configuration* tab for the Add-on to start. -**Note:** Luakit browser is launched in kiosk-like (*passthrough*) mode.\ -To enter *normal* mode (similar to command mode in `vi`), press -`ctl-alt-esc`.\ -You can then return to *passthrough* mode by pressing `ctl-Z` or enter -*insert* mode by pressing `i`.\ -See luakit documentation for available commands.\ -In general, you want to stay in `passthrough` mode. +**NOTE:** The Add-on requires a valid, connected display in order to +start.\ +If your display does not show up, try rebooting and restarting the Add-on +with the display attached **NOTE:** Should support any standard mouse, touchscreen, keypad and -touchpad so long as their /dev/input/eventN number is less than 25. +touchpad so long as its `/dev/input/eventN` number is less than 25. -**NOTE:** If not working, please first check the bug reports (open and +**NOTE:** If you encounter issues with the Add-on, please first check the +HAOSKiosk github +[issues page](https://github.com/puterboy/HAOS-kiosk/issues) (open and closed), then try the testing branch (add the following url to the -repository: https://github.com/puterboy/HAOS-kiosk#testing). If still no -solution, file an issue on github -[bug report](https://github.com/puterboy/HAOS-kiosk/issues) and include -full details of your setup and what you did along with a complete log. +repository: https://github.com/puterboy/HAOS-kiosk#testing). If still +please file an +[issue on github](https://github.com/puterboy/HAOS-kiosk/issues) and +\*\*include full details of your setup (including computer hardware and +display type details)and what you did along with a complete log. ### If you appreciate my efforts: @@ -50,6 +48,43 @@ full details of your setup and what you did along with a complete log. ______________________________________________________________________ +## Installation + +1. Click the **ADD ADD-ON REPOSITORY** button below. + + [![Open your Home Assistant instance and show the add Add-on repository dialog with a specific repository URL pre-filled.](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2Fputerboy%2FHAOS-kiosk) + + - Click **Add → Close** (You might need to enter the **internal IP + address** of your Home Assistant instance first) *or* go to the + **Add-on store**. + - Click **⋮ → Repositories** + - Fill in `https://github.com/puterboy/HAOS-kiosk` + - Click **Add → Close** + +2. Click on the Add-on, press **Install** and wait until the Add-on is + installed. + +3. You must enter your HA username and password in the **Configuration** + tab. + +4. Press **Start** to run the Add-on. + +**If you are having trouble installing the add-on or getting displays and +touchscreens working, please see the github issues page +(https://github.com/puterboy/HAOS-kiosk/issues)as many common issues have +already been addressed and resolved** + +### Notes + +- If screen is not working on an RPi3, try adding the following lines to + the `[pi3]` section of your `config.txt` on the boot partition: + ``` + dtoverlay=vc4-fkms-v3d + max_framebuffers=2 + ``` + +______________________________________________________________________ + ## Configuration Options ### HA Username [required] @@ -206,8 +241,9 @@ Note for security REST server only listens on localhost (127.0.0.1) ### REST Bearer Token -Optional authorization token for REST API. (Default: "") If set, then add -line `-H "Authorization: Bearer "` to REST API calls. +Optional authorization token for REST API. (Default: "") If set, then you +must add line `-H "Authorization: Bearer "` to REST API +calls. ### Debug @@ -226,18 +262,31 @@ examples, and default gestures. ### Command Whitelist Regex -Regex (Python) of shell command that can be used in creating gesture +Regex (Python) of shell command that can be used in creating gesture action commands or when running the `run_command` and `run_commands` REST APIs. If left blank, then all commands are allowed except for those blacklisted as dangerous (otherwise, whitelist overrides internal blacklist). +The pre-defined command blacklist includes commands like: + +``` + python + ash, bash, sh, su + env, exec + kill, killall, pkill + cp, chmod, chown, dd, ln, mv, rm, tar + mount, umount + curl, nc, wget + find, xargs" +``` + Note that if you want to truly allow *all* commands, then use the wildcard `.*` but beware that it is DANGEROUS. If you want to disallow all commands set the regex to `^$`. Note that regardless of setting only commands found in `/bin`, `/usr/bin`, -and `/usr/local/bin` of the HAOSKiosk add-on container are allowed. +and `/usr/local/bin` of the HAOSKiosk Add-on container are allowed. ______________________________________________________________________ @@ -246,10 +295,14 @@ ______________________________________________________________________ ### launch_url {"url": "\"} Launch the specified 'url' in the kiosk display. Overwrites current active -tab. +tab. If no url supplied, use HA_URL/HA_DASHBOARD as default url. Usage: -`curl -X POST http://localhost:/launch_url -H "Content-Type: application/json" -d '{"url": ""}'` + +``` +curl -X POST http://localhost:/launch_url +curl -X POST http://localhost:/launch_url -H "Content-Type: application/json" -d '{"url": ""}' +``` ### refresh_browser @@ -277,7 +330,7 @@ Usage: ``` curl -X POST http://localhost:/display_on -curl -X POST http://localhost:8080/display_on -H "Content-Type: application/json" -d '{"timeout": } +curl -X POST http://localhost:8080/display_on -H "Content-Type: application/json" -d '{"timeout": }' ``` ### display_off @@ -291,24 +344,20 @@ Usage: ### xset Run `xset ` to get/set display information. In particular, use `-q` -to get display information. +to get display information. Can only be run from localhost unless +REST_BEARER_TOKEN set and used. Usage: `curl -X POST http://localhost:/xset -H "Content-Type: application/json" -d '{"args": ""}'` -### current_processes - -Return number of currently running concurrent processes out of max allowed - -Usage: `curl -X GET http://localhost:8080/current_processes` - ### run_command {"cmd": "\"} Run `command` in the HAOSKiosk Docker container where `cmd_timeout` is an optional timeout in seconds. -Only allowed if `Allow User Commands` option is set to true. +Commands are subject to blacklist/whitelist rules detailed above. Can only +be run from localhost unless REST_BEARER_TOKEN set and used. Usage: @@ -319,7 +368,8 @@ Usage: Run multiple commands in the HAOSKiosk Docker container where `cmd_timeout` is an optional timeout in seconds. -Only allowed if `Allow User Commands` option is set to true. +Commands are subject to blacklist/whitelist rules detailed above. Can only +be run from localhost unless REST_BEARER_TOKEN set and used. Usage: @@ -348,7 +398,57 @@ You can format the stdout (and similarly stderr) by piping the output to: In the case of `run_commands`, pipe the output to: `jq -r '.results[]?.stdout'` -______________________________________________________________________ +### current_processes + +Return number of currently running concurrent processes out of max allowed. + +Usage: `curl -X GET http://localhost:8080/current_processes` + +### disable_inputs + +Disable keyboard and pointer (e.g., mouse, touch) inputs. Can only be run +from localhost unless REST_BEARER_TOKEN set and used. + +Usage: + +`curl -X POST http://localhost:/disable_inputs` + +### enable_inputs + +(Re)enable keyboard and pointer (e.g., mouse, touch) inputs. Can only be +run from localhost unless REST_BEARER_TOKEN set and used. + +Usage: + +`curl -X POST http://localhost:/enable_inputs` + +### mute_audio + +Mute audio output for default audio sink. Returns final mute state. + +Usage: + +`curl -X POST http://localhost:/mute_audio` + +### unmute_audio {"volume": "\"} + +Unmute audio output for default audio sink. Optionally, set volume (integer +between 0 and 150). Returns final volume and mute state + +Usage: + +``` +curl -X POST http://localhost:/unmute_audio +curl -X POST http://localhost:8080/unmute_audio -H "Content-Type: application/json" -d '{"volume": }' +``` + +### toggle_audio + +Toggle mute for default audio sink. Returns final mute state. + +Usage: + +`curl -X POST http://localhost:/toggle_audio` #### HA REST Command Syntax @@ -386,11 +486,6 @@ rest_command: content_type: "application/json" payload: "{}" - haoskiosk_current_processes: - url: "http://localhost:8080/current_processes" - method: GET - content_type: "application/json" - haoskiosk_xset: url: "http://localhost:8080/xset" method: POST @@ -408,6 +503,39 @@ rest_command: method: POST content_type: "application/json" payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmds": {{ cmds | tojson }}, "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmds": {{ cmds | tojson }}}{% endif %}' + + haoskiosk_current_processes: + url: "http://localhost:8080/current_processes" + method: GET + content_type: "application/json" + + haoskiosk_disable_inputs: + url: "http://localhost:8080/disable_inputs" + method: POST + content_type: "application/json" + payload: "{}" + + haoskiosk_enable_inputs: + url: "http://localhost:8080/enable_inputs" + method: POST + content_type: "application/json" + payload: "{}" + + haoskiosk_mute_audio: + url: "http://localhost:8080/mute_audio" + method: POST + content_type: "application/json" + + haoskiosk_unmute_audio: + url: "http://localhost:8080/unmute_audio" + method: POST + content_type: "application/json" + payload: '{"volume": "{{ volume }}"}' + + haoskiosk_toggle_audio: + url: "http://localhost:8080/toggle_audio" + method: POST + content_type: "application/json" ``` Note if optional \`REST_BEARER_TOKEN~ is set, then add the following two @@ -425,6 +553,7 @@ The rest commands can then be referenced from automation actions as: ``` actions: + - action: rest_command.haoskiosk_launch_url - action: rest_command.haoskiosk_launch_url data: url: "https://homeassistant.local/my_dashboard" @@ -440,8 +569,6 @@ actions: - action: rest_command.haoskiosk_display_off - - action: rest_command.haoskiosk_current_processes - - action: rest_command.haoskiosk_xset data: args: "" @@ -454,11 +581,25 @@ actions: - action: rest_command.haoskiosk_run_commands data: cmds: - -- "" + - "" - "" ... cmd_timeout: + + - action: rest_command.haoskiosk_current_processes + + - action: rest_command.haoskiosk_disable_inputs + + - action: rest_command.haoskiosk_enable_inputs + + - action: rest_command.haoskiosk_mute_audio + + - action: rest_command.haoskiosk_unmute_audio + - action: rest_command.haoskiosk_unmute_audio + data: + volume: 100 + + - action: rest_command.haoskiosk_toggle_audio ``` ### REST API Use Cases @@ -485,7 +626,7 @@ actions: ______________________________________________________________________ -### GESTURE COMMANDS +## Gesture Commands Each Gesture Command is a JSON-like key-value pair where the key is a valid *Gesture String* corresponding to a specific sequence of button clicks or @@ -495,7 +636,7 @@ set of one or more commands to execute when the gesture is recognized. The formats of the Gesture Strings and Action Commands are precisely defined, so if they fail to load check your log for error messages. -#### Gesture String Keys +### Gesture String Keys Each Gesture String key is of form: @@ -531,7 +672,7 @@ of the gesture `Long Tap`). Note the names may be device-specific (e.g., Click for Mouse, Tap for Touch) -##### Additional notes regarding gesture naming: +#### Additional notes regarding gesture naming: - `ANY` is a wildcard matching any gesture - Corners are named: CORNER_TOPLEFT, CORNER_TOPRIGHT, CORNER_BOTLEFT, @@ -576,9 +717,9 @@ always enter keys from particular to more general when using wildcards 1-Touch_2-Long (Long gestures must be single contact) ``` -#### Action Command Values +### Action Commands -Action command values may be expressed in one of three forms: +Action commands may be expressed in one of three forms: 1. **Single command string** e.g., `"ls -a -l"` Note that an empty string acts as a No-Op -- i.e., it will be ignored and can be used with @@ -587,14 +728,13 @@ Action command values may be expressed in one of three forms: 2. **List of commands** - Each command may be either: - A string: `"echo hello"` - - An argv-style list: `["ls", "-a", "-l"]` + - An argv-style list: `["ls", "-a", "-l"]` Example `["echo hello", ["ls", "-a", "-l"]]` Note that the commands themselves can either be shell commands or -kiosk-specific internal commands that begin with the prefix 'kiosk.' - -Examples include: +kiosk-specific internal commands (see below) that begin with the prefix +'kiosk.' 3. **Command dictionary** with required key `"cmds":` and optional keys: `"msg":` and `"timeout"` @@ -606,7 +746,19 @@ Examples: {"cmds": ["echo hello", ["ls", "-al"]], "msg": "echo hello and list all files", "timeout": 5} ``` -#### Defaults & Examples +#### Kiosk-specific internal commands + +- **kiosk.back**: Go back in browser history +- **kiosk.forward**: Go forward in browser history +- **kiosk.refresh_browser**: Reload current page +- **kiosk.launch_url **: Launch in existing tab/window.\ + If no given, use HA_URL/HA_DASHBOARD as default +- **kiosk.display_on **: Turn on display with optional timeout +- **kiosk.display_off**: Turn off display immediately +- **kiosk.toggle_keyboard**: Toggle onscreen keyboard +- **kiosk.toggle_audio**: Toggle mute state of default audio sink + +### Defaults Gesture Command Bindings The following gestures are included by default (but can be removed by clicking on the `X` next to them): @@ -614,59 +766,117 @@ clicking on the `X` next to them): - **Single Tap or Click in Top Right Corner**: *Toggle on-screen keyboard* ``` -"1_ANY_1_CORNER_TOPRIGHT": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"1_ANY_1_CORNER_TOPRIGHT": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **Left Triple Mouse Click**: *Toggle on-screen keyboard* ``` -"[Left]_MOUSE_3_CLICK": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"[Left]_MOUSE_3_CLICK": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **3-Finger Single Tap**: *Toggle on-screen keyboard* ``` -"3_TOUCH_1_TAP": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} +"3_TOUCH_1_TAP": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."} ``` - **3-Finger Double Tap**: *Refresh Browser* ``` -"3_TOUCH_2_TAP": {"cmds": [["xdotool", "key", "--clearmodifiers ctrl+r"]], "msg": "Refresh Browser"} +"3_TOUCH_2_TAP": {"cmds": "kiosk.refresh_browser", "msg": "Refresh Browser"} ``` -- **3-Finger Triple Tap**: *Restore Default HA dashboard - (HA_URL/HA_Dashboard)* +- **3-Finger Triple Tap**: *Toggle audio mute* ``` -"3_TOUCH_3_TAP": {"cmds": "luakit \"$HA_URL/$HA_DASHBOARD\"", "msg": "Restore default dashboard"} +"3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' ``` - **3-Finger Left Swipe**: *Go forward one element in browser history* ``` -"3_TOUCH_1_SWIPE_LEFT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Right"]], "msg": "Go forward in the history browser"} +"3_TOUCH_1_SWIPE_LEFT": {"cmds": "kiosk.forward", "msg": "Go forward in the history browser"} ``` - **3-Finger Right Swipe**: *Go back one element in browser history* ``` -"3_TOUCH_1_SWIPE_RIGHT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Left"]], "msg": "Go back in the history browser"} +"3_TOUCH_1_SWIPE_RIGHT": {"cmds": "kiosk.back", "msg": "Go back in the history browser"} +``` + +- **2-Finger Triple Tap**: *Restore Default HA dashboard + (HA_URL/HA_Dashboard)* + +``` +"2_TOUCH_3_TAP": {"cmds": "kiosk.launch_url", "msg": "Restore default dashboard: HA_URL/HA_DASHBOARD"} ``` -- **2-Finger Triple Tap**: *Open Google search* +- **2-Finger Quadruple Tap**: *Open Google search* ``` -"2_TOUCH_3_TAP": {"cmds": "luakit \"www.google.com\"", "msg": "Open Google search"}' +"2_TOUCH_4_TAP": {"cmds": [["kiosk.lauch_url", "www.google.com"]], "msg": "Open Google search"}' +``` + +Note if you didn't have the built-in functions, you could have manually +implemented the above as: + +``` +"1_ANY_1_CORNER_TOPRIGHT": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"[Left]_MOUSE_3_CLICK": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"3_TOUCH_1_TAP": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."} + +"3_TOUCH_2_TAP": {"cmds": [["xdotool", "key", "--clearmodifiers ctrl+r"]], "msg": "Refresh Browser"} + +"3_TOUCH_3_TAP": {"cmds": [["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"]], "msg": "Toggle audio mute"}' + +"3_TOUCH_1_SWIPE_LEFT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Right"]], "msg": "Go forward in the history browser"} + +"3_TOUCH_1_SWIPE_RIGHT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Left"]], "msg": "Go back in the history browser"} + +"2_TOUCH_3_TAP": {"cmds": "luakit \"$HA_URL/$HA_DASHBOARD\"", "msg": "Restore default dashboard"} + +"2_TOUCH_4_TAP": {"cmds": "luakit \"www.google.com\"", "msg": "Open Google search"}' ``` ______________________________________________________________________ +## KEYBOARD SHORTCUTS + +The following fixed keyboard shortcuts are defined (but subject to change). + +- **Ctrl+r:** *Reload page* + +- **Ctrl+LeftArrow:** *Go back in the browser tab history* + +- **Ctrl+RightArrow:** *Go forward in the browser tab history* + +- **Ctrl+Alt+t:** *Open new tab* + +- **Ctrl+Alt+Shift+t:** *Close current tab* + +- **Ctrl+Alt+w:** *Open new window* + +______________________________________________________________________ + ## MISCELLANEOUS NOTES -- If screen is not working on an RPi3, try adding the following lines to - the `[pi3]` section of your `config.txt` on the boot partition: - ``` - dtoverlay=vc4-fkms-v3d - max_framebuffers=2 - ``` +#### Luakit browser + +The Luakit browser is launched in kiosk-like (*passthrough*) mode. In +general, you want to stay in `passthrough` mode to preserve the kiosk-like +experience and pass all keystrokes to the browser page (except for explicit +bindings as defined above) + +Luakit modes and commands are similar to vi + +- To enter *normal* mode (similar to command mode in `vi`), press + `ctl-alt-esc`. + +- To return to *passthrough* mode, press `ctl-Z` or alternatively, press + `i` to enter *insert* + +See [luakit documentation](https://wiki.archlinux.org/title/Luakit) for +further usage information and available commands. diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 6a1b8d6..4a6ede9 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test1" +version: "1.3.0-test3" slug: "haoskiosk" arch: @@ -90,14 +90,15 @@ options: rest_bearer_token: "" debug_mode: false gestures: - - '"1_ANY_1_CORNER_TOPRIGHT": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."}' - - '"[Left]_MOUSE_3_CLICK": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."}' - - '"3_TOUCH_1_TAP": {"cmds": [["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"]], "msg": "Toggling Onboard keyboard..."}' - - '"3_TOUCH_2_TAP": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+r"]], "msg": "Refresh Browser"}' - - '"3_TOUCH_3_TAP": {"cmds": "luakit \"$HA_URL/$HA_DASHBOARD\"", "msg": "Restore default dashboard"}' - - '"3+_TOUCH_1_SWIPE_LEFT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Right"]], "msg": "Go forward in the history browser"}' - - '"3+_TOUCH_1_SWIPE_RIGHT": {"cmds": [["xdotool", "key", "--clearmodifiers", "ctrl+Left"]], "msg": "Go back in the history browser"}' - - '"2_TOUCH_3_TAP": {"cmds": "luakit \"www.google.com\"", "msg": "Open Google search"}' + - '"1_ANY_1_CORNER_TOPRIGHT": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' + - '"[Left]_MOUSE_3_CLICK": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' + - '"3_TOUCH_1_TAP": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' + - '"3_TOUCH_2_TAP": {"cmds": "kiosk.refresh_browser", "msg": "Refresh Browser"}' + - '"3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' + - '"3+_TOUCH_1_SWIPE_LEFT": {"cmds": "kiosk.forward", "msg": "Go forward in the history browser"}' + - '"3+_TOUCH_1_SWIPE_RIGHT": {"cmds": "kiosk.back", "msg": "Go back in the history browser"}' + - '"2_TOUCH_3_TAP": {"cmds": "kiosk.launch_url", "msg": "Restore default dashboard: HA_URL/HA_DASHBOARD"}' + - '"2_TOUCH_4_TAP": {"cmds": [["kiosk.launch_url", "www.google.com"]], "msg": "Open Google search"}' command_whitelist: "cat|date|dbus-send|echo|false|grep|head|ls|luakit|notify-send|ping|ping6|ps|pstree|sleep|tail|test|top|tree|true|xdotool|xset" schema: diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py index 8d337f9..f175cae 100755 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -2,14 +2,25 @@ "exec" "sudo" "$(dirname $(readlink -f $0))/venv/bin/python3" "$0" "$@" #"exec" "$(dirname $0)/venv/bin/python3" "$0" "$@" #Above lines used to invoke venv relative to current directory -#See: https://stackoverflow.com/questions/20095351/shebang-use-interpreter-relative-to-the-script-path +#See: https://stackoverflow.com/questions/20095351/shebang-use-interpreter-relative-to-the-script-path # pylint: disable=line-too-long #Below shebang line only works if call strict from the script directory #!$(dirname $0)/venv/bin/python3 #Below shebang line only works if already activated virtual environment #!/usr/bin/env python3 - -################################################################################ +#=============================================================================== +# pylint: disable=line-too-long +# pylint: disable=invalid-name +# pylint: disable=too-many-instance-attributes +# pylint: disable=broad-except +# pylint: disable=too-many-arguments +# pylint: disable=too-many-positional-arguments +# pylint: disable=too-many-branches +# pylint: disable=too-many-statements +# pylint: disable=too-many-locals +# pylint: disable=too-many-lines +# pylint: disable=global-statement +#=============================================================================== # Add-on: HAOS Kiosk Display (haoskiosk) # File: ultrasonic-trigger.py # Version: 1.1.0 @@ -21,35 +32,32 @@ # - Print out distance every LOOPTIME seconds # - Turn on monitor if distance < NEAR_ON_DIST for COUNT_ON_THRESH seconds # - Turn off monitor if distance > FAR_OFF_DIST for COUNT_OFF_THRESH seconds +# - Also turn on/off audio if ULTRASONIC_AUDIO is True # # When measuring distance: # - Take GPIO_READINGS_TO_AVERAGE and average the valid ones # - Mark as invalid measurement if more than half of the readings are errors # - Restart if more than INVALID_COUNT_THRESHOLD invalid measurements in a row # -# Also, optionally, if the HA sensor HA_BINARY_SENSOR is set and evaluates to true, -# then don't measure distance and leave display in DEFAULT_DISPLAY_STATE -# This can be used to make the auto on/off depend on the state of a sensor in HA. -# +# Optionally, if HA_BINARY SENSOR is set, then: +# - If HA_DISPLAY_TOGGLE is True/False, then keep display always on when HA_BINARY_SENSOR is on/off; +# Ignore if None +# - If HA_AUDIO_TOGGLE is True/False then mute audio when HA_BINARY_SENSOR is on/off; +# Ignore if None +# - If HA_INPUTS_TOGGLE is True/False then disable inputs when HA_BINARY_SENSOR is on/off; +# Ignore if None +# - If HA_ROTATE_URLS is True/False then rotate urls when HA_BINARY_SENSOR is on/off +# Ignore if None +# This can be used to make the display, input, and audio states depend on the +# on/off state of the HA_BINARY_SENSOR sensor # # NOTES: # - Requires adding the following Python libraries: pyftdi, requests # Probably best to install in venv so it persists reboots # - Should run as root (e.g., 'sudo') # -################################################################################ -# pylint: disable=line-too-long -# pylint: disable=invalid-name -# pylint: disable=too-many-instance-attributes -# pylint: disable=broad-except -# pylint: disable=too-many-arguments -# pylint: disable=too-many-positional-arguments -# pylint: disable=too-many-branches -# pylint: disable=too-many-statements -# pylint: disable=too-many-locals -# pylint: disable=too-many-lines -################################################################################ - +#=============================================================================== +### Imports import logging import os @@ -63,6 +71,55 @@ from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry +#------------------------------------------------------------------------------- +### Configuration Variables + +# Configure ultrasonic sensor readings +TRIG_PIN: int = 0 # AD0 - Output +ECHO_PIN: int = 1 # AD1 - Input +GPIO_READINGS_TO_AVERAGE: int = 5 # Number of distance readings to average +WAIT_TIMEOUT:float = 0.05 # Timeout for wait_for_pin (seconds) (this is conservative) + # Note HC-SR04 pulls pin low after 38ms (which with speed of sound 343m/s is equivalent to ~6.5m each way) +# HA general variables +HA_PORT = 8123 +HA_BEARER_TOKEN: str| None = None # Needed if using HA_BINARY_SENSOR + +ULTRASONIC_AUDIO: bool = True # Use ultrasonic to mute/unmute audio also if True + +HA_BINARY_SENSOR: str | None = None # Optional binary sensor to determine whether to measure distance and turn on/off display +HA_BINARY_SENSOR_FRIENDLY_NAME: str| None = None #Optional Friendly Name for binary sensor + +HA_DISPLAY_TOGGLE: bool | None = True # If True/False then keep display always on (and ignore distance) when HAS_BINARY_SENSOR is on/off; Ignore if None +HA_AUDIO_TOGGLE: bool | None = True # If True/False then mute audio when HA_BINARY_SENSOR is on/off; Ignore if None +HA_INPUTS_TOGGLE: bool | None = True # If True/False then disable inputs when HA_BINARY_SENSOR is on/off; Ignore if None +HA_ROTATE_TOGGLE: bool | None = True # If True/False then rotate urls in ROTATE_URL_LIST when HA_BINARY_SENSOR is on/off; Ignore if None +ROTATE_URL_LIST: list[str] = [] # Rotate URL is None or non-empty list of URL strings +ROTATE_FREQ: int = 30 # Number of loops between URL rotations (nominally equal to seconds if LOOP_TIME = 1) + +# Configure REST API +REST_PORT: int = 8080 +REST_BEARER_TOKEN: str = "" + +# Other parameters +LOOP_TIME: int = 1 # Target loop time (seconds) - i.e., target time between distance measurements +NEAR_ON_DIST: int = 150 # Near distance threshold (in cm) before turning display on +FAR_OFF_DIST: int = 200 # Far distance threshold (in cm) before turning display off +COUNT_ON_THRESH: int = 2 # Number of 'near' distance measurements before turning on +COUNT_OFF_THRESH: int = 4 # Number of 'far' distance measurements before turning off + +INVALID_COUNT_THRESHOLD: int = 10 # Number of consecutive invalid measurements before restarting + +HTTP_TIMEOUT: int = 3 # Timeout for HTTP get and posts (in seconds) + +#=============================================================================== +### Setup +if HA_BINARY_SENSOR_FRIENDLY_NAME is None and HA_BINARY_SENSOR is not None: + #Get string after last '.', replace '_' with space, capitalize words + HA_BINARY_SENSOR_FRIENDLY_NAME = HA_BINARY_SENSOR.rsplit('.', 1)[-1].replace('_', ' ').title() + +if not ROTATE_URL_LIST: + HA_ROTATE_TOGGLE = None + #Relaunch 'unbuffered' if not already unbuffered so that you can pipe output real-time if desired if os.environ.get('PYTHONUNBUFFERED') != '1': os.environ['PYTHONUNBUFFERED'] = '1' @@ -80,55 +137,13 @@ ) logger = logging.getLogger(__name__) -################################################################################ -### Configurable variables - -# Configure ultrasonic sensor readings -TRIG_PIN = 0 # AD0 - Output -ECHO_PIN = 1 # AD1 - Input -GPIO_READINGS_TO_AVERAGE = 5 # Number of distance readings to average -WAIT_TIMEOUT = 0.05 # Timeout for wait_for_pin (seconds) (this is conservative) - # Note HC-SR04 pulls pin low after 38ms (which with speed of sound 343m/s is equivalent to ~6.5m each way) -# HA general variables -HA_PORT = 8123 -HA_BEARER_TOKEN = None # Needed if using HA_BINARY_SENSOR - -HA_BINARY_SENSOR=None # Optional binary sensor to determine whether to measure distance and turn on/off display -HA_BINARY_SENSOR_FRIENDLY_NAME=None #Optional Friendly Name for binary sensor -if HA_BINARY_SENSOR_FRIENDLY_NAME is None and HA_BINARY_SENSOR is not None: - #Get string after last '.', replace '_' with space, capitalize words - HA_BINARY_SENSOR_FRIENDLY_NAME = HA_BINARY_SENSOR.rsplit('.', 1)[-1].replace('_', ' ').title() - -DEFAULT_DISPLAY_STATE=True # Default display state if HA_BINARY_SENSOR is 'True' (False=off; True=on) - -# Configure REST API -REST_PORT = 8080 -REST_BEARER_TOKEN="" - -# Other parameters -LOOP_TIME = 1 # Target loop time (seconds) - i.e., target time between distance measurements -NEAR_ON_DIST = 150 # Near distance threshold (in cm) before turning display on -FAR_OFF_DIST = 200 # Far distance threshold (in cm) before turning display off -COUNT_ON_THRESH = 2 # Number of 'near' distance measurements before turning on -COUNT_OFF_THRESH = 4 # Number of 'far' distance measurements before turning off - - -INVALID_COUNT_THRESHOLD= 10 # Number of consecutive invalid measurements before restarting -HTTP_TIMEOUT = 3 # Timeout for HTTP get and posts - -################################################################################ ### Ultrasonic distance sensing - TRIG_MASK = 1 << TRIG_PIN ECHO_MASK = 1 << ECHO_PIN - -# Setup ultrasonic sensor gpio = GpioController() -try: - gpio.configure('ftdi://ftdi:232h/1', direction=TRIG_MASK) # TRIG = output, ECHO = input -except Exception as e: - logger.error("Ultrasonic trigger GPIO initialization failed...exiting (%s)", e) - sys.exit(1) + +#=============================================================================== +### Subroutines def handle_exit(_signum: int, _frame: types.FrameType | None) -> None: """Exit handler""" @@ -138,6 +153,24 @@ def handle_exit(_signum: int, _frame: types.FrameType | None) -> None: for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): signal.signal(sig, handle_exit) +def cleanup() -> None: + """Cleanup before exiting...""" + date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + print() + try: + if display is False: + display_on_print() # Turn display and audio back on... + if HA_INPUTS_TOGGLE is not None: + ha_disable_inputs(False) # Restore inputs + print(f"[{date_time}] Restoring inputs...") + if ULTRASONIC_AUDIO is True or HA_AUDIO_TOGGLE is not None: + ha_mute_audio(False) # Unmute audio + print(f"[{date_time}] Restoring audio...") + gpio.close() + except Exception as e: + logger.error("Error: GPIO close failed (%s)", e) + print(f"[{date_time}] Exiting...") + def send_trigger_pulse()-> bool: """Send ultrasonic trigger pulse""" try: @@ -208,8 +241,8 @@ def measure_distance() -> float | None: invalid_count = 0 # Reset invalid counter return sum(distances) / len(distances) if distances else None -################################################################################ -### HAOKiosk monitor control +#=============================================================================== +### HAOKiosk Api calls # Setup HTTP retry session = requests.Session() @@ -285,7 +318,7 @@ def display_off() -> bool: return False last_display_time = datetime.now() -def display_on_print() -> None: +def display_on_print(audio_too: bool=False) -> None: """Turn on display and show duration since last on""" global last_display_time old_display_time = last_display_time @@ -294,13 +327,17 @@ def display_on_print() -> None: display_time_diff = display_time_diff - timedelta(microseconds=display_time_diff.microseconds) if display_on(): - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display ON*** (Duration: {display_time_diff})") + msg = "" + if audio_too: + ha_mute_audio(False) # Also umute audio + msg = " and restoring audio" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display ON{msg}*** (Duration: {display_time_diff})") global display display = True else: logger.error("FAILED to turn display ON") -def display_off_print() ->None: +def display_off_print(audio_too: bool=False) ->None: """Turn off display and show duration since last off""" global last_display_time old_display_time = last_display_time @@ -309,13 +346,17 @@ def display_off_print() ->None: display_time_diff = display_time_diff - timedelta(microseconds=display_time_diff.microseconds) if display_off(): - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display OFF*** (Duration: {display_time_diff})") + msg = "" + if audio_too: + ha_mute_audio(True) # Also mute audio + msg = " and muting audio" + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display OFF{msg}*** (Duration: {display_time_diff})") global display display = False else: logger.error("FAILED to turn display OFF") -def ha_binary_sensor_state(sensor: str) -> bool | None: +def ha_binary_sensor_state(sensor: str | None) -> bool | None: """Show state of binary sensor used to turn/off ultrasonic-governed display mechanism""" if sensor is None: return None @@ -339,14 +380,85 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: logger.error("HTTP Request failed (%s)", e) return None -################################################################################ +def ha_disable_inputs(state: bool) -> bool: + """Disable/enable inputs""" + if state: + url = f"http://localhost:{REST_PORT}/disable_inputs" + else: + url = f"http://localhost:{REST_PORT}/enable_inputs" + + try: + response = session.post( + url, + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"} + ) + response.raise_for_status() + data = response.json() + if not data.get("success", False): + return False + return True + except (requests.RequestException, ValueError): + return False + +def ha_mute_audio(state: bool) -> bool: + """Mute/unmute audio""" + if state: + url = f"http://localhost:{REST_PORT}/mute_audio" + else: + url = f"http://localhost:{REST_PORT}/unmute_audio" + + try: + response = session.post( + url, + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"} + ) + response.raise_for_status() + data = response.json() + if not data.get("success", False): + return False + return True + except (requests.RequestException, ValueError): + return False + +def ha_launch_url(site: str) -> bool: + """Launch url""" + url = f"http://localhost:{REST_PORT}/launch_url" + try: + response = session.post( + url, + headers={"Authorization": f"Bearer {REST_BEARER_TOKEN}"}, + json={"url": site} + ) + response.raise_for_status() + data = response.json() + if not data.get("success", False) or not data.get("result", {}).get("success", False): + logging.debug("Failed to launch_url: %s", url) + return False + stdout_text = data["result"].get("stdout", "") + return "Monitor is On" in stdout_text + except (requests.RequestException, ValueError) as e: + logger.error("HTTPRequest failed (%s)", e) + return False + +#=============================================================================== ### Main loop display = False -loop_num = -1 -count = 0 -binary_sensor_state = None -try: +def main()-> None: + """Main event loop""" + + # Setup ultrasonic sensor + try: + gpio.configure('ftdi://ftdi:232h/1', direction=TRIG_MASK) # TRIG = output, ECHO = input + except Exception as e: + logger.error("Ultrasonic trigger GPIO initialization failed...exiting (%s)", e) + sys.exit(1) + + loop_num = -1 + count = 0 + binary_sensor_state = None + + # Main event loop while True: loop_start = time.monotonic() loop_num += 1 @@ -355,16 +467,36 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: old_binary_sensor_state = binary_sensor_state binary_sensor_state = ha_binary_sensor_state(HA_BINARY_SENSOR) if binary_sensor_state is not None and binary_sensor_state != old_binary_sensor_state: # Status of binary_sensor_state changed - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] '{HA_BINARY_SENSOR_FRIENDLY_NAME}' = {binary_sensor_state}") - if binary_sensor_state: # Binary sensor turned on so set to default state - if DEFAULT_DISPLAY_STATE: - display_on_print() # Default true - else: - display_off_print() # Default false + date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + print(f"[{date_time}] '{HA_BINARY_SENSOR_FRIENDLY_NAME}' = {binary_sensor_state}") + if HA_DISPLAY_TOGGLE is not None: + if binary_sensor_state == HA_DISPLAY_TOGGLE: + display_on_print(audio_too=ULTRASONIC_AUDIO and HA_AUDIO_TOGGLE is None) # Turn on display (because need to keep it always on) + date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + if HA_INPUTS_TOGGLE is not None: + state = binary_sensor_state == HA_INPUTS_TOGGLE + ha_disable_inputs(state) + print(f"[{date_time}] ***{"Disabling" if state else "Enabling"} inputs***") + if HA_AUDIO_TOGGLE is not None: + state = binary_sensor_state == HA_AUDIO_TOGGLE + ha_mute_audio(state) + print(f"[{date_time}] ***{"Muting" if state else "Unmuting"} audio***") + if HA_ROTATE_TOGGLE is not None and binary_sensor_state != HA_ROTATE_TOGGLE: # Reset to first url + new_url = ROTATE_URL_LIST[0] + ha_launch_url(new_url) # Restore default (first) url + date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + print(f"[{date_time}] Restoring: {new_url}") + if not binary_sensor_state: display_state_print() # Set and show display state every 60 seconds - if binary_sensor_state: # Avoid calculating distance & turning on/off display + if display is True and HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE and not loop_num % ROTATE_FREQ: # Rotate url + new_url = ROTATE_URL_LIST[(loop_num // ROTATE_FREQ) % len(ROTATE_URL_LIST)] + ha_launch_url(new_url) + date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + print(f"[{date_time}] Rotating url: {new_url}") + + if HA_DISPLAY_TOGGLE is not None and binary_sensor_state == HA_DISPLAY_TOGGLE: # Avoid calculating distance & turning on/off display time.sleep(LOOP_TIME) continue @@ -376,12 +508,12 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: count = max(count, 0) count += 1 if display is False and count >= COUNT_ON_THRESH: - display_on_print() # Turn ON display + display_on_print(audio_too=ULTRASONIC_AUDIO) # Turn ON display elif distance > FAR_OFF_DIST: count = min(count, 0) count -= 1 if display is True and count <= -COUNT_OFF_THRESH: - display_off_print() # Turn OFF display + display_off_print(audio_too=ULTRASONIC_AUDIO) # Turn OFF display else: print("Distance: Invalid") @@ -391,15 +523,15 @@ def ha_binary_sensor_state(sensor: str) -> bool | None: if sleep_time > 0: time.sleep(sleep_time) -finally: +#=============================================================================== + +if __name__ == "__main__": try: - if display is False: - display_on_print() # Turn display back on... - gpio.close() - except Exception as e: - logger.error("Error: GPIO close failed (%s)", e) - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Exiting...") + main() + finally: + cleanup() +#=============================================================================== # vim: set filetype=python : # Local Variables: # mode: python diff --git a/haoskiosk/gesture_commands.json b/haoskiosk/gesture_commands.json index 5b33149..e1c59b8 100644 --- a/haoskiosk/gesture_commands.json +++ b/haoskiosk/gesture_commands.json @@ -3,7 +3,7 @@ # File: gesture_commands.json # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 # ------------------------------------------------------------------------------ # USER CUSTOMIZABLE GESTURES — loaded BEFORE built-in # defaults in CMD_DICTand thus have higher precedence since diff --git a/haoskiosk/mouse_touch_inputs.py b/haoskiosk/mouse_touch_inputs.py index fcbc6f9..ca03d2e 100644 --- a/haoskiosk/mouse_touch_inputs.py +++ b/haoskiosk/mouse_touch_inputs.py @@ -14,7 +14,7 @@ # File: MouseTouchInputs # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 # #### DESCRIPTION: Full-featured X11 parser and command launcher for multi-button press and @@ -378,6 +378,7 @@ XINPUT_RESTART_DELAY: int = 5 # Seconds before restarting xinput after crash CMD_TIMEOUT: int | None = 30 # Seconds before spawned action command timesout or None if no timeout GESTURE_CMDS_FILES: list[str] = ["/data/options.json", "gesture_commands.json"] +DEFAULT_LAUNCH_URL = f"{(os.getenv('HA_URL') or 'about:blank').rstrip('/')}/{os.getenv('HA_DASHBOARD') or ''}".strip('/') #------------------------------------------------------------------------------- ## Initialization @@ -520,7 +521,7 @@ def debug(level: int, msg: str) -> None: def is_valid_url(url: str) -> bool: """Validate URL format (allows http://, https://, bare domain/IP, path, query, fragment).""" - return bool(VALID_URL_REGEX.fullmatch(url.strip())) + return bool(url == 'about:blank' or VALID_URL_REGEX.fullmatch(url.strip())) #------------------------------------------------------------------------------- #### Globals @@ -587,7 +588,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if missing: # Missing parameters raise ValueError(f"{fullname}: Missing required parameters: {missing}") - extra = [k for k in data if k not in allowed_params and k != "timeout"] + extra = [k for k in data if not k.startswith('_') and k not in allowed_params and k != "timeout"] + # Note always allow parameters beginning with '_' (internal) and 'timeout' if extra: # Extra parameters raise ValueError(f"{fullname}: Unknown parameters: {extra}") @@ -644,16 +646,16 @@ def handle_refresh_browser(timeout: int | None = None, *, _cmd_name: str = "unkn """Reload current page.""" _run_subprocess(["xdotool", "key", "--clearmodifiers", "ctrl+r"], timeout=timeout, description=_cmd_name) -@register_function("launch_url") -def handle_launch_url(url: str, timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: - """Launch URL — validates URL""" +@register_function("launch_url", optional=["url"]) +def handle_launch_url(url: str = DEFAULT_LAUNCH_URL, timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: + """Launch (valid) URL if given otherwise HA_URL/HA_DASHBOARD if exists otherwise 'about:blank'""" if not isinstance(url, str): raise ValueError(f"{_cmd_name}: URL must be str, got {type(url).__name__}") if not url.strip(): - raise ValueError("{_cmd_name}: URL cannot be empty or whitespace") + raise ValueError(f"{_cmd_name}: URL cannot be empty or whitespace: {url}") if not is_valid_url(url): - raise ValueError(f"{_cmd_name}: Invalid URL format") - if not url.startswith(("http://", "https://")): + raise ValueError(f"{_cmd_name}: Invalid URL format: {url}") + if url != "about:blank" and not url.startswith(("http://", "https://")): url = "http://" + url _run_subprocess(["luakit", "-n", url], timeout=timeout, description=_cmd_name) @@ -683,6 +685,16 @@ def handle_display_off(timeout: int | None = None, *, _cmd_name: str = "unknown" """Force display off immediately.""" _run_subprocess(["xset", "dpms", "force", "off"], timeout=timeout, description=_cmd_name) +@register_function("toggle_keyboard") +def handle_toggle_keyboard(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: + """Toggle onscreen keyboard.""" + _run_subprocess(["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"], timeout=timeout, description=_cmd_name) + +@register_function("toggle_audio") +def handle_toggle_audio(timeout: int | None = None, *, _cmd_name: str = "toggle_audio") -> None: + """Toggle mute state of the default audio sink.""" + _run_subprocess(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], timeout=timeout, description=_cmd_name) + #------------------------------------------------------------------------------- #### Utility Functions def truncate_time(t: float | None) -> str: @@ -1175,7 +1187,7 @@ def spec(self) -> DeviceSpec: # CommandsType can be: # - Single command string containing either shell commands (e.g., "ls -a -l") # or internally defined functions (e.g., "kiosk.back") -# - List of one or more commands in either of the above 2 forms +# - List of one or more commands in either of the following 2 forms # - String form (e.g., "ls -a -l") # - List of argv-style component string (e.g., ["ls", "-a", "-l"]) # Examples include: @@ -1794,50 +1806,61 @@ def is_command_allowed(command_str: str) -> tuple[bool, str]: #pylint: disable= # 1. Command = Argv-sytle list if isinstance(cmd, list): + + # 1a: Internal Command list + if cmd[0] in FunctionRegistry: + function = FunctionRegistry[cmd[0]] + arguments = cmd[1:] + descr = f"Internal List function: {cmd!r}" + def run_function(time_out: int | None = None, func: Callable[..., None] = function, args: list[str] = arguments, descr: str = descr) -> None: + debug(2, descr) + func(*args, timeout=time_out) + execs_list.append(run_function) + continue + + # 1b: Shell Command list cmd_str = " ".join(shlex.quote(str(x)) for x in cmd) allowed, reason = is_command_allowed(cmd_str) if not allowed: raise ValueError(f"Command '{cmd!r}' blocked: {reason}") - descr = f"List Command: {cmd!r}" - - def run(time_out: int | None = None, args: list[str] = cmd, descr: str = descr) -> None: - _run_subprocess(args, timeout=time_out, description=descr) - execs_list.append(run) + descr = f"Shell List command: {cmd!r}" + def run_command(time_out: int | None = None, command: str | list[str] = cmd, descr: str = descr) -> None: + _run_subprocess(command, timeout=time_out, description=descr) + execs_list.append(run_command) continue if not isinstance(cmd, str): # Should not happen since we tested validity before raise TypeError(f"Command must be string or list, got {type(cmd)}: {cmd!r}") # 2. Command = string - cmd_str = cmd.strip() - if not cmd_str: # Blank command # Should not happen since we tested validity before + cmd = cmd.strip() + if not cmd: # Blank command # Should not happen since we tested validity before continue - parts = shlex.split(cmd_str) + parts = shlex.split(cmd) cmd_name = parts[0] - # 2a: Internal Command + # 2a: Internal Command string if cmd_name in FunctionRegistry: function = FunctionRegistry[cmd_name] arguments = parts[1:] - descr = f"Internal function: {cmd_str}" - - def run(time_out: int | None = None, #type: ignore[misc] #pylint: disable=function-redefined #This is a mypy and pylint bug - func: Callable[..., None] = function, args: list[str] = arguments, descr: str = descr) -> None: + descr = f"Internal String function: {cmd}" + def run_function(time_out: int | None = None, #pylint: disable=function-redefined #This is a mypy and pylint bug + func: Callable[..., None] = function, args: list[str] = arguments, descr: str = descr) -> None: debug(2, descr) func(*args, timeout=time_out) - execs_list.append(run) + execs_list.append(run_function) + continue - # 2b: Shell Command - (allowed, reason) = is_command_allowed(cmd_str) + # 2b: Shell Command string + (allowed, reason) = is_command_allowed(cmd) if not allowed: - raise ValueError(f"Command '{cmd_str}' : {reason}") - descr = f"String command: {cmd_str}" - - def run(time_out: int | None = None, #type: ignore[misc] #pylint: disable=function-redefined #This is a mypy and pylint bug - cmd: str = cmd_str, descr: str = descr) -> None: - _run_subprocess(cmd, timeout=time_out, description=descr) # _run_subprocess will decide whether to use shell - execs_list.append(run) + raise ValueError(f"Command '{cmd}' : {reason}") + descr = f"Shell String command: {cmd}" + def run_command(time_out: int | None = None, #pylint: disable=function-redefined #This is a mypy and pylint bug + command: str | list[str] = cmd, descr: str = descr) -> None: + _run_subprocess(command, timeout=time_out, description=descr) + execs_list.append(run_command) return {"cmds": value, "execs": execs_list, "msg": msg, "timeout": timeout} @@ -2601,7 +2624,7 @@ def __next__(self) -> XInputEventFilled: class CommandError(RuntimeError): """ Error class for subprocess.run""" -def _run_subprocess(args: str | Sequence[str], *, shell: bool | None = None, timeout: int | None = CMD_TIMEOUT, description: str) -> None: +def _run_subprocess(args: str | Sequence[str], *, shell: bool | None = None, timeout: int | None = CMD_TIMEOUT, description: str) -> subprocess.CompletedProcess[str]: """Output and Debugging-enabled wrapper around subprocess_run""" if isinstance(args, str): # If string, test if needs shell @@ -2633,6 +2656,7 @@ def _run_subprocess(args: str | Sequence[str], *, shell: bool | None = None, tim if result.returncode != 0: raise CommandError(f"Command failed (exit={result.returncode}): {description}") debug(2, f"Execution success: {description}") + return result except subprocess.TimeoutExpired as e: raise CommandError(f"Timeout for '{prog}' after {timeout}s [{"shell" if use_shell else "exec"}]: {description}") from e @@ -2645,6 +2669,7 @@ def _run_subprocess(args: str | Sequence[str], *, shell: bool | None = None, tim except Exception as e: raise CommandError(f"Failed to execute '{prog}' command [{"shell" if use_shell else "exec"}]: {description}") from e + return subprocess.CompletedProcess[str](args=args, returncode=1, stdout="", stderr=str(e),) def execute_commands(cmds_dict: CommandsDict) -> None: """ Execute one or more executable commands from the CommandsDict""" diff --git a/haoskiosk/rest_server.py b/haoskiosk/rest_server.py index 96d71ce..46fedbc 100644 --- a/haoskiosk/rest_server.py +++ b/haoskiosk/rest_server.py @@ -3,7 +3,7 @@ # File: services.py # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 Launch REST API server with following commands: POST /launch_url {"url": ""} @@ -18,7 +18,7 @@ For security: - Defaults to listening only on 127.0.0.1 (localhost) - - Requires REST_BEARER_TOKEN if caller is not localhost + - Requires REST_BEARER_TOKEN for protected commands if caller is not localhost - Commands must: - Satisfy whitelist regex - Not be on blacklist @@ -52,6 +52,8 @@ import re import shlex import shutil +import signal +import subprocess import sys from contextlib import suppress from functools import wraps @@ -80,6 +82,8 @@ MAX_CONCURRENT_COMMANDS: int = 5 SHORT_TIMEOUT: int = 5 # Timeout used for simple commands +DEFAULT_LAUNCH_URL = f"{(os.getenv('HA_URL') or 'about:blank').rstrip('/')}/{os.getenv('HA_DASHBOARD') or ''}".strip('/') + # --------------------------------------------------------------------------- # # Security Model @@ -148,7 +152,7 @@ def is_valid_url(url: str) -> bool: """Validate URL format (allows http://, https://, bare domain/IP, path, query, fragment).""" - return bool(VALID_URL_REGEX.fullmatch(url.strip())) + return bool(url == 'about:blank' or VALID_URL_REGEX.fullmatch(url.strip())) # --------------------------------------------------------------------------- # # Setup @@ -384,7 +388,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: if missing: # Missing parameters raise ValueError(f"{fullname}: Missing required parameters: {missing}") - extra = [k for k in data if k not in allowed_params and k != "timeout"] + extra = [k for k in data if not k.startswith('_') and k not in allowed_params and k != "timeout"] + # Note always allow parameters beginning with '_' (internal) and 'timeout' if extra: # Extra parameters raise ValueError(f"{fullname}: Unknown parameters: {extra}") @@ -430,6 +435,8 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: # --------------------------------------------------------------------------- # PROTECTED_COMMANDS = { # These commands can only be run on localhost unless REST_BEARER_TOKEN set and used + "disable_inputs", + "enable_inputs", "run_command", "run_commands", "xset", # if you want @@ -440,13 +447,15 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: "current_processes" } -@register_function("launch_url", required=["url"], validators={"url": is_valid_url}) +### URL & Refresh +@register_function("launch_url", optional=["url"], validators={"url": is_valid_url}) async def handle_launch_url(data: Payload) -> dict[str, Any]: """Launch browser with given URL.""" - url = str(data["url"]) - if not url.startswith(("http://", "https://")): + url = str(data["url"]) if data.get("url") else DEFAULT_LAUNCH_URL + if url != "about:blank" and not url.startswith(("http://", "https://")): url = "http://" + url - result = await execute_command(f"luakit -n '{url}' &", log_prefix="launch_url", allow_command=True) + asyncio.create_task(execute_command(["luakit", "-n", url], log_prefix="launch_url", allow_command=True)) # Run in the background + result = {"success": True, "stdout": "", "stderr": "", "returncode": 0} return {"success": result["success"], "result": result} @register_function("refresh_browser") @@ -456,6 +465,7 @@ async def handle_refresh_browser(data: Payload) -> dict[str, Any]: # pylint: di timeout=SHORT_TIMEOUT, log_prefix="refresh_browser", allow_command=True) return {"success": result["success"]} +### Display @register_function("is_display_on") # GET endpoint – we register manually below async def handle_is_display_on(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument """Return boolean whether monitor is currently on.""" @@ -496,6 +506,19 @@ async def handle_display_off(data: Payload) -> dict[str, Any]: # pylint: disabl timeout=SHORT_TIMEOUT, log_prefix="display_off", allow_command=True) return {"success": result["success"]} +@register_function("xset", required=["args"], validators={"args": lambda x: isinstance(x, str) and bool(x.strip())}) +async def handle_xset(data: Payload) -> dict[str, Any]: + """Run arbitrary xset command (sanitized).""" + args = data["args"] + # Block dangerous shell metacharacters — even with allow_all_user_commands=False + dangerous_tokens = [tok for tok in DANGEROUS_SHELL_TOKENS if tok in args] + if dangerous_tokens: + return {"success": False, "error": "Forbidden shell metacharacters in xset args: {dangerous_tokens}"} + args_list = shlex.split(args) # Convert to list for safer execution + result = await execute_command(["xset"] + args_list, timeout=SHORT_TIMEOUT, log_prefix="xset", allow_command=True) + return {"success": result["success"], "result": result} + +### Commands and processes @register_function("current_processes") # GET endpoint async def handle_current_processes(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument """Report number of currently running subprocesses.""" @@ -509,18 +532,6 @@ async def handle_current_processes(data: Payload) -> dict[str, Any]: # pylint: "max_allowed": MAX_CONCURRENT_COMMANDS, } -@register_function("xset", required=["args"], validators={"args": lambda x: isinstance(x, str) and bool(x.strip())}) -async def handle_xset(data: Payload) -> dict[str, Any]: - """Run arbitrary xset command (sanitized).""" - args = data["args"] - # Block dangerous shell metacharacters — even with allow_all_user_commands=False - dangerous_tokens = [tok for tok in DANGEROUS_SHELL_TOKENS if tok in args] - if dangerous_tokens: - return {"success": False, "error": "Forbidden shell metacharacters in xset args: {dangerous_tokens}"} - args_list = shlex.split(args) # Convert to list for safer execution - result = await execute_command(["xset"] + args_list, timeout=SHORT_TIMEOUT, log_prefix="xset", allow_command=True) - return {"success": result["success"], "result": result} - @register_function("run_command", required=["cmd"], optional=["cmd_timeout"], validators={"cmd_timeout": lambda x: x is None or (isinstance(x, int) and x > 0)}) async def handle_run_command(data: Payload) -> dict[str, Any]: @@ -549,6 +560,226 @@ async def handle_run_commands(data: Payload) -> dict[str, Any]: return {"success": all(r["success"] for r in results), "results": results} + +### Turn on/off inputs +# List of inputs to skip when enabling/disabling inputs +INPUT_IGNORE_LIST = [ "XTEST", "Power Button", "Video Bus", "Sleep Button", "Consumer Control", "System Control" ] + +def get_input_devices() -> dict[str, str]: + """Returns a dict of {device_name: /dev/input/eventN}, excluding devices in IGNORE_LIST.""" + devices = {} + result = subprocess.run(["libinput", "list-devices"], capture_output=True, text=True, check=True) + + dev_name = None + + for line in result.stdout.splitlines(): + line = line.strip() + if line.startswith("Device:"): + dev_name = line.split("Device:", 1)[1].strip() + elif line.startswith("Kernel:") and dev_name: + kernel_path = line.split("Kernel:", 1)[1].strip() + if not any(ignore in dev_name for ignore in INPUT_IGNORE_LIST): # pylint: disable=unsupported-membership-test + devices[kernel_path] = dev_name + dev_name = None + + return devices + +async def get_running_evtest_processes(timeout: int = SHORT_TIMEOUT) -> dict[str, list[int]]: + """Returns { '/dev/input/eventX': [pid1, pid2, ...] } for active evtest --grab processes.""" + try: + proc = await asyncio.create_subprocess_exec( + "ps", "ax", "-o", "pid,args", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout) + if proc.returncode != 0: + logger.warning("ps failed with code %d: %s", proc.returncode, stderr.decode(errors="replace")) + return {} + output = stdout.decode(errors="replace") + except asyncio.TimeoutError: + logger.warning("ps timed out after %s seconds", timeout) + return {} + except (OSError, subprocess.SubprocessError) as e: + logger.warning("Failed to get ps output: %s", e) + return {} + + result: dict[str, list[int]] = {} + pattern = re.compile(r'^\s*(\d+)\s+evtest\s+--grab\s+(/dev/input/event\d+)(?:\s|$)', re.MULTILINE) + matches = pattern.findall(output) + + for pid_str, path in matches: + try: + result.setdefault(path, []).append(int(pid_str)) + except ValueError: + continue + + return result + +@register_function("disable_inputs") +async def handle_disable_inputs(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument + """Disable inputs by blocking each input with 'evtest'""" + devices = get_input_devices() + running = await get_running_evtest_processes() + + new_pids = [] + skipped_devices = 0 + for path, name in devices.items(): + if path in running and running[path]: # Already disabled by running evtest process + skipped_devices += 1 + continue + if not os.path.exists(path): + logger.error("Input event path not found: %s (%s)", path, name) + continue + try: + proc = await asyncio.create_subprocess_exec( + "evtest", "--grab", path, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + start_new_session=True, + ) + new_pids.append(proc.pid) + logger.info("DISABLED: %s [%s] (pid=%d)", name, path, proc.pid) + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Couldn't start evtest and disable input '%s' [%s] (%s)", name, path, e) + + return { + "success": True, + "new_pids": new_pids, + "skipped_devices" : skipped_devices, + } + +@register_function("enable_inputs") +async def handle_enable_inputs(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument + """Re-enable inputs by killing corresponding 'evtest' processes""" + devices = get_input_devices() + running = await get_running_evtest_processes() + + killed_pids = [] + skipped_devices = 0 + for path, name in devices.items(): + pids = running.get(path, []) + if not pids: + skipped_devices += 1 + continue + for pid in pids: + try: + os.killpg(os.getpgid(pid), signal.SIGTERM) + killed_pids.append(pid) + logger.info ("ENABLED: %s [%s] (evtest pid=%d)", name, path, pid) + except Exception as e: # pylint: disable=broad-exception-caught + logger.warning("Failed to kill pid %d for %s [%s]: %s", pid, name, path, e) + + return { + "success": True, + "killed_pids": killed_pids, + "skipped_devices": skipped_devices, + } + +#### Audio +@register_function("mute_audio") +async def handle_mute_audio(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument + """Mute the default audio sink.""" + + commands = [ + ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "1"], + ["pactl", "get-sink-mute", "@DEFAULT_SINK@"], # End with a state check + ] + + results = [] + success = True + for cmd in commands: + result = await execute_command(cmd, print_stdout=False, timeout=SHORT_TIMEOUT, + log_prefix="mute_audio", allow_command=True) + results.append(result) + success = success and result["success"] + if not success: + break # Stop early on failure + + if success and "Mute: yes" in results[-1]["stdout"]: + logger.info("Audio muted") + else: + logger.error("Failed to mute audio") + + return { + "success": success, + "mute_state": "muted" if "Mute: yes" in results[-1]["stdout"] else "unmuted" if results[-1]["stdout"] else "NA", #pylint: disable=multiple-statements + "results": results, + } + +@register_function("unmute_audio", optional=["volume"], + validators={"volume": lambda x: x is None or (isinstance(x, int) and 0 <= x <= 150)}) +async def handle_unmute_audio(data: Payload) -> dict[str, Any]: + """Unmute the default audio sink, optionally set volume level (0-150%).""" + set_volume = data.get("volume") + + commands=[["pactl", "set-sink-mute", "@DEFAULT_SINK@", "0"]] # Unmute first + if set_volume is not None: # If volume provided, set it after unmute + commands.append(["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{int(set_volume)}%"]) + commands.append(["pactl", "get-sink-volume", "@DEFAULT_SINK@"]) + commands.append(["pactl", "get-sink-mute", "@DEFAULT_SINK@"]) # End with a state check + + results = [] + success = True + for cmd in commands: + result = await execute_command(cmd, print_stdout=False, timeout=SHORT_TIMEOUT, + log_prefix="unmute_audio", allow_command=True) + results.append(result) + success = success and result["success"] + if not success: + break # Stop early on failure + + if success and "Mute: no" in results[-1]["stdout"]: + msg = "Audio unmuted" + volume_raw = results[-2]["stdout"] + if volume_raw.startswith("Volume"): + vol_pattern = re.compile(r"(\S+):\s*\d+\s*/\s*(\d+)%\s*/") + volumes = {name: int(pct) for name, pct in vol_pattern.findall(volume_raw)} + msg = f"{msg}: {volumes}" + logger.info(msg) + else: + logger.error("Failed to unmute/set volume") + + return { + "success": success, + "volumes": volumes, + "mute_state": "muted" if "Mute: yes" in results[-1]["stdout"] else "unmuted" if results[-1]["stdout"] else "NA", #pylint: disable=multiple-statements + "results": results, + } + +@register_function("toggle_audio") +async def handle_toggle_audio(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument + """Toggle mute state of the default audio sink.""" + + commands = [ + ["pactl", "get-sink-mute", "@DEFAULT_SINK@"], # End with a state check + ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], + ["pactl", "get-sink-mute", "@DEFAULT_SINK@"], # End with a state check + ] + + results = [] + success = True + for cmd in commands: + result = await execute_command(cmd, print_stdout=False, timeout=SHORT_TIMEOUT, + log_prefix="toggle_audio", allow_command=True) + results.append(result) + success = success and result["success"] + if not success: + break # Stop early on failure + + if success and len(results) == 3 and ( # Check if pre and post mute state differ + ("Mute: yes" in results[0]["stdout"]) != ("Mute: yes" in results[-1]["stdout"])): + logger.info("Audio mute state toggled") + else: + logger.error("Failed to toggle audio mute state") + + return { + "success": success, + "mute_state": "muted" if "Mute: yes" in results[-1]["stdout"] else "unmuted" if results[-1]["stdout"] else "NA", #pylint: disable=multiple-statements + + "results": results, + } + # --------------------------------------------------------------------------- # # Security middleware # --------------------------------------------------------------------------- # @@ -560,7 +791,7 @@ async def security_middleware( """ aiohttp middleware that: - Enforces Bearer token authentication. - - Blocks PROTECTED_COMMANDS if not calling from localhost/127.0.0.1 + - Blocks PROTECTED_COMMANDS if not calling from localhost/127.0.0.1 or REST_BEARER_TOKEN not set Note: If REST_BEARER_TOKEN environment variable is set (non-empty), every incoming request must contain the header: Authorization: Bearer diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index d8d2851..e194be0 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -5,7 +5,7 @@ # File: run.sh # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 # # Code does the following: # - Import and sanity-check the following variables from HA/config.yaml @@ -154,6 +154,14 @@ if [ -z "$HA_USERNAME" ] || [ -z "$HA_PASSWORD" ]; then exit 1 fi +################################################################################ +### GTK and DBUS-related environment variables to improve stability + +export NO_AT_BRIDGE=1 # Stop GTK from touching at-spi bus +export GTK_USE_PORTAL=0 # Disable portals +export GIO_USE_VFS=local # Local-only GIO +export DBUS_SESSION_BUS_TIMEOUT=5000 # Shorten DBUS timeouts +export GTK_CSD=0 # Disable client side decorations (???) ################################################################################ #### Start Dbus # Start dbus-daemon to Avoids waiting for DBUS timeouts (e.g., luakit) diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index d09a7aa..fcf0ebe 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -3,14 +3,14 @@ Add-on: HAOS Kiosk Display (haoskiosk) File: userconf.lua for HA minimal browser run on server Version: 1.3.0 Copyright Jeff Kosowsky -Date: January 2026 +Date: February 2026 Code does the following: - Sets browser window to fullscreen - Sets zooms level to value of $ZOOM_LEVEL (default 100%) - Loads every URL in 'passthrough' mode so that you can type text as needed without triggering browser commands - - Auto-logs in to Home Assistant using $HA_USERNAME and $HA_PASSWORD - - Redefines key to return to normal mode (used for commands) from 'passthrough' mode to: 'Ctl-Alt-Esc' + - Auto login to Home Assistant using $HA_USERNAME and $HA_PASSWORD + - Redefines key to return to normal mode (used for commands) from 'passthrough' mode to: 'Ctl+Alt+Esc' (rather than just 'Esc') to prevent unintended returns to normal mode and activation of unwanted commands - Adds binding to reload browser screen (all modes) - Adds and bindings, to move backwards and forwards respectively in the browser history @@ -40,7 +40,7 @@ local modes = package.loaded["modes"] -- ----------------------------------------------------------------------- -- Configurable variables -local new_escape_key = "" -- Ctl-Alt-Esc +local new_escape_key = "" -- Ctl+Alt+Esc local HARD_RELOAD_FREQ = 10 -- Frequency of fully reloading cache when refreshing page local MAX_LOAD_FAILURES = 5 -- Maximum number of consecutive page (re)load failures per view before restarting luakit @@ -155,7 +155,6 @@ end local unique_instance = require "unique_instance" unique_instance.open_link_in_current_tab = true - -- ----------------------------------------------------------------------- -- Helper functions local function single_quote_escape(str) -- Single quote strings before injection into JS @@ -466,22 +465,16 @@ webview.add_signal("init", function(view) end) -- ----------------------------------------------------------------------- --- Redefine to 'new_escape_key' (e.g., ) to exit current mode and enter normal mode +-- Redefine to 'new_escape_key' (e.g., Ctl+Alt+Esc>) to exit current mode and enter normal mode +-- modes.remove_binds({"passthrough"}, {""}) -modes.add_binds("passthrough", { + +modes.add_binds("all", { -- Add to all modes (note modes other than 'passhtrough' still accept Escape too) {new_escape_key, "Switch to normal mode", function(w) w:set_prompt() w:set_mode() -- Use this if not redefining 'default_mode' since defaults to "normal" --- w:set_mode("normal") -- Use this if redefining 'default_mode' [Option#3] - end} -} -) --- Add binding in all modes to reload page -modes.add_binds("all", { - { "", "Reload page", function(w) w:reload() end }, - { "", "Go back in the browser history", function(w, m) w:back(m.count) end }, - { "", "Go forward in the browser history", function(w, m) w:forward(m.count) end }, - }) + end }, +}) -- Clear the command line when entering passthrough instead of typing '-- PASS THROUGH --' modes.get_modes()["passthrough"].enter = function(w) @@ -492,3 +485,25 @@ modes.get_modes()["passthrough"].enter = function(w) end -- ----------------------------------------------------------------------- +modes.add_binds("all", { + -- Browser history and reload + { "", "Reload page", function(w) w:reload() end }, + { "", "Go back in the browser history", function(w, m) w:back(m.count) end }, + { "", "Go forward in the browser history", function(w, m) w:forward(m.count) end }, + + -- New/Close tab and window + { "", "Open new tab", function(w) w:new_tab("about:blank") end }, + { "", "Close current tab", function(w) w:close_tab() end }, + { "", "Open new window", function() window.new() end }, +-- { "", "Close current window", function(w) w:close_window() end }, -- DOESN'T WORK + + -- Tab navigation +-- { "", "Go to previous tab", function(w) w:prev_tab() end }, -- DOESN'T WORK +-- { "", "Go tonext tab", function(w) w:next_tab() end }, -- DOESN'T WORK + + -- Window navigation +-- { "", "Focus previous window", function() window.focus_prev() end }, -- DOESN'T WORK +-- { "", "Focus next window", function() window.focus_next() end }, -- DOESN'T WORK +}) + +-- ----------------------------------------------------------------------- diff --git a/haoskiosk/xorg.conf.default b/haoskiosk/xorg.conf.default index ef9b2d1..24a962a 100644 --- a/haoskiosk/xorg.conf.default +++ b/haoskiosk/xorg.conf.default @@ -3,7 +3,7 @@ # File: xorg.conf # Version: 1.3.0 # Copyright Jeff Kosowsky -# Date: January 2026 +# Date: February 2026 # # Minimal xorg.conf to work with OpenGL/DRI video and libinput mice & keyboards # From 3846473c4f745a9bf80f3f5707d3d3ac6bd1f61c Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 02:06:59 -0500 Subject: [PATCH 07/16] - Added screenshot REST API - Added more key bindings --- README.md | 114 ++++++++++++++++++++--- haoskiosk/CHANGELOG.md | 2 + haoskiosk/Dockerfile | 91 ++++++++++-------- haoskiosk/README.md | 114 ++++++++++++++++++++--- haoskiosk/config.yaml | 10 +- haoskiosk/examples/ultrasonic-trigger.py | 13 ++- haoskiosk/mouse_touch_inputs.py | 57 ++++++++++-- haoskiosk/rest_server.py | 74 +++++++++++++-- haoskiosk/run.sh | 84 +++++++++++++++++ haoskiosk/translations/en.yaml | 15 ++- haoskiosk/userconf.lua | 34 +++++-- 11 files changed, 508 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 542c5be..be2417a 100644 --- a/README.md +++ b/README.md @@ -245,14 +245,6 @@ Optional authorization token for REST API. (Default: "") If set, then you must add line `-H "Authorization: Bearer "` to REST API calls. -### Debug - -For debugging purposes, launches `Xorg` and `openbox` and then sleeps -without launching `luakit`.\ -Manually, launch `luakit` (e.g., -`luakit -U localhost:8123/`) from Docker container.\ -E.g., `sudo docker exec -it addon_haoskiosk bash` - ### Gestures Editable list of JSON-like key-value pairs where the key represents a @@ -288,6 +280,26 @@ set the regex to `^$`. Note that regardless of setting only commands found in `/bin`, `/usr/bin`, and `/usr/local/bin` of the HAOSKiosk Add-on container are allowed. +### VNC SERVER + +Launch VNC Server on port 5900 if password non-blank. If password set to +'-', then don't require any password. This can be used to view or debug the +kiosk remotely. + +To view launch a vnc server (e.g., RealVNC) and point it to port 5900 on +your homeassistant instance (e.g., `homeassistant:5900`) + +*Use with caution* as it runs unencrypted and is accessible anywhere on +your network. + +### Debug + +For debugging purposes, launches `Xorg` and `openbox` and then sleeps +without launching `luakit`.\ +Manually, launch `luakit` (e.g., +`luakit -U localhost:8123/`) from Docker container.\ +E.g., `sudo docker exec -it addon_haoskiosk bash` + ______________________________________________________________________ ## REST APIs @@ -398,6 +410,22 @@ You can format the stdout (and similarly stderr) by piping the output to: In the case of `run_commands`, pipe the output to: `jq -r '.results[]?.stdout'` +### screenshot + +Take screen screenshot with optional filename, quality, and delay before +screenshot. Quality only affects jpeg images + +Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or +.tiff + +Usage: + +``` +curl -X POST http://localhost:8080/screenshot +curl -X POST http://localhost:8080/screenshot -H "Content-Type: application/json" + -d '{"filename": "", "quality": <1-100>, "delay: }' +``` + ### current_processes Return number of currently running concurrent processes out of max allowed. @@ -478,7 +506,13 @@ rest_command: url: "http://localhost:8080/display_on" method: POST content_type: "application/json" - payload: '{% if timeout is defined and timeout is number and timeout >= 0 %}{"timeout": {{ timeout | int }}}{% else %}{}{% endif %}' + payload: >- + {{ + { + 'timeout': timeout | int if timeout is defined and timeout is number and timeout >= 0 else none + } + | to_json + }} haoskiosk_display_off: url: "http://localhost:8080/display_off" @@ -496,13 +530,42 @@ rest_command: url: "http://localhost:8080/run_command" method: POST content_type: "application/json" - payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmd": "{{ cmd }}", "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmd": "{{ cmd }}"}{% endif %}' + payload: >- + {{ + { + 'cmd': cmd, + 'cmd_timeout': cmd_timeout | int if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 else none + } + | to_json + }} + haoskiosk_run_commands: url: "http://localhost:8080/run_commands" method: POST content_type: "application/json" - payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmds": {{ cmds | tojson }}, "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmds": {{ cmds | tojson }}}{% endif %}' + payload: >- + {{ + { + 'cmds': cmds, + 'cmd_timeout': cmd_timeout | int if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 else none + } + | to_json + }} + + haoskiosk_screenshot: + url: "http://localhost:8080/screenshot" + method: POST + content_type: "application/json" + payload: >- + {{ + { + 'delay': delay | int if delay is defined and delay | int(0) >= 0 else none, + 'filename': filename if filename is defined and filename != "" and "/" not in filename and "\0" not in filename else none, + 'quality': quality | int if quality is defined and 1 <= quality | int <= 100 else none + } + | to_json + }} haoskiosk_current_processes: url: "http://localhost:8080/current_processes" @@ -530,7 +593,13 @@ rest_command: url: "http://localhost:8080/unmute_audio" method: POST content_type: "application/json" - payload: '{"volume": "{{ volume }}"}' + payload: >- + {{ + { + 'volume': volume | int if volume is defined and volume is number and 0 <= volume | int <= 150 else none + } + | to_json + }} haoskiosk_toggle_audio: url: "http://localhost:8080/toggle_audio" @@ -845,13 +914,16 @@ ______________________________________________________________________ ## KEYBOARD SHORTCUTS -The following fixed keyboard shortcuts are defined (but subject to change). +The following new fixed keyboard shortcuts are defined (but subject to +change). + +- **Ctrl+o:** *Toggle Onboard onscreen keyboard* - **Ctrl+r:** *Reload page* -- **Ctrl+LeftArrow:** *Go back in the browser tab history* +- **Ctrl+Left:** *Go back in the browser tab history* -- **Ctrl+RightArrow:** *Go forward in the browser tab history* +- **Ctrl+Right:** *Go forward in the browser tab history* - **Ctrl+Alt+t:** *Open new tab* @@ -859,6 +931,18 @@ The following fixed keyboard shortcuts are defined (but subject to change). - **Ctrl+Alt+w:** *Open new window* +- **Ctl+Alt+Left:** *Previous tab* + +- **Ctl+Alt+Right:** *Next tab* + +- **Ctl+Alt+Shift+Left:** *Previous window* (Also: **Alt+Shift+Tab**) + +- **Ctl+Alt+Shift+Right:** *Next window* (Also: **Alt+Tab**) + +- **Ctrl+Alt+k:** *Take screenshot and save to /media/screenshots* + +Note that the Onbox Window manager defines many other default bindings. + ______________________________________________________________________ ## MISCELLANEOUS NOTES diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index 446c6e8..e1fc2bc 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.3.0 - February 2026 +- Add x11vnc server to facilitate remote viewing or debugging of kiosk +- Added 'screenshot' functoin to REST_API and gestue action commands - Added `enable_inputs` and `disable_inputs` functions to REST_API to allow locking down (and unlocking) inputs by disabling keyboard, mouse and touch functions diff --git a/haoskiosk/Dockerfile b/haoskiosk/Dockerfile index 80623bb..eab7495 100644 --- a/haoskiosk/Dockerfile +++ b/haoskiosk/Dockerfile @@ -12,50 +12,66 @@ FROM $BUILD_FROM ARG BUILD_VERSION ENV ADDON_VERSION=${BUILD_VERSION} -# Install Luakit and necessary dependencies +#=============================================================================== +##### Install X, Browser and all necessary dependencies RUN apk update && apk add --no-cache \ - xorg-server \ - xf86-video-modesetting \ - xf86-input-libinput \ - libinput \ -# libinput-tools \ - udev \ - libinput-udev \ - libevdev \ - mesa-dri-gallium \ - mesa-egl \ - mesa-gles \ - libdrm \ - libxkbcommon \ - ttf-dejavu \ - util-linux \ - xdotool \ - xinput \ - xrandr \ - xset \ - evtest \ - unclutter-xfixes \ - setxkbmap \ - openbox \ - onboard \ +## Xserver and graphics + xorg-server \ + xf86-video-modesetting \ + mesa-dri-gallium \ + mesa-egl \ + mesa-gles \ + libdrm \ +### X utils + xdotool \ + xinput \ + xrandr \ + xset \ +# xev \ + libxkbcommon \ + setxkbmap \ +### Input, udev + xf86-input-libinput \ + libinput \ +# libinput-tools \ + udev \ + libinput-udev \ + libevdev \ + evtest \ +### Window, mouse, keyboard management + openbox \ + onboard \ + unclutter-xfixes \ + ttf-dejavu \ +### Linux utils + bash \ + util-linux \ + patch \ + scrot \ +### Full Timezone info + icu-data-full \ +### Python libraries py3-pip \ py3-xlib \ - patch \ - bash \ - pulseaudio-utils \ -# alsa-utils alsa-plugins-pulse \ -# gstreamer-tools \ -# gst-libav \ -# VNC (for debugging) - run: x11vnc -display :0 -forever & \ - x11vnc \ +### Sound + pulseaudio-utils \ +# alsa-utils alsa-plugins-pulse \ +### Video +# gstreamer-tools \ +# gst-libav \ +### VNC (for debugging) - run: x11vnc -display :0 -forever & \ + x11vnc \ +# Browser # luakit \ - && apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/v3.21/community luakit=2.3.6-r0 \ - && rm -rf /var/cache/apk/* + && apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/v3.21/community luakit=2.3.6-r0 \ + && rm -rf /var/cache/apk/* -# Set the display variable +#=============================================================================== + +##### Set the display variable ENV DISPLAY=:0 -# Copy over 'xorg.conf.default' and lua 'userconf.lua' file +##### Copy over 'xorg.conf.default' and lua 'userconf.lua' file COPY xorg.conf.default /etc/X11/ COPY userconf.lua /root/.config/luakit/ COPY translations/*.yaml /translations/ @@ -67,6 +83,7 @@ COPY mouse_touch_inputs.py gesture_commands.json / COPY rest_server.py / RUN pip install --no-cache-dir --break-system-packages aiohttp #Required for rest_server.py +#### Patches # Need to patch 'unique_instance.lua' so that new instance urls overwrite active url rather than add new tab COPY unique_instance.patch /usr/share/luakit/lib RUN patch -p2 /usr/share/luakit/lib/unique_instance.lua < /usr/share/luakit/lib/unique_instance.patch diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 542c5be..be2417a 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -245,14 +245,6 @@ Optional authorization token for REST API. (Default: "") If set, then you must add line `-H "Authorization: Bearer "` to REST API calls. -### Debug - -For debugging purposes, launches `Xorg` and `openbox` and then sleeps -without launching `luakit`.\ -Manually, launch `luakit` (e.g., -`luakit -U localhost:8123/`) from Docker container.\ -E.g., `sudo docker exec -it addon_haoskiosk bash` - ### Gestures Editable list of JSON-like key-value pairs where the key represents a @@ -288,6 +280,26 @@ set the regex to `^$`. Note that regardless of setting only commands found in `/bin`, `/usr/bin`, and `/usr/local/bin` of the HAOSKiosk Add-on container are allowed. +### VNC SERVER + +Launch VNC Server on port 5900 if password non-blank. If password set to +'-', then don't require any password. This can be used to view or debug the +kiosk remotely. + +To view launch a vnc server (e.g., RealVNC) and point it to port 5900 on +your homeassistant instance (e.g., `homeassistant:5900`) + +*Use with caution* as it runs unencrypted and is accessible anywhere on +your network. + +### Debug + +For debugging purposes, launches `Xorg` and `openbox` and then sleeps +without launching `luakit`.\ +Manually, launch `luakit` (e.g., +`luakit -U localhost:8123/`) from Docker container.\ +E.g., `sudo docker exec -it addon_haoskiosk bash` + ______________________________________________________________________ ## REST APIs @@ -398,6 +410,22 @@ You can format the stdout (and similarly stderr) by piping the output to: In the case of `run_commands`, pipe the output to: `jq -r '.results[]?.stdout'` +### screenshot + +Take screen screenshot with optional filename, quality, and delay before +screenshot. Quality only affects jpeg images + +Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or +.tiff + +Usage: + +``` +curl -X POST http://localhost:8080/screenshot +curl -X POST http://localhost:8080/screenshot -H "Content-Type: application/json" + -d '{"filename": "", "quality": <1-100>, "delay: }' +``` + ### current_processes Return number of currently running concurrent processes out of max allowed. @@ -478,7 +506,13 @@ rest_command: url: "http://localhost:8080/display_on" method: POST content_type: "application/json" - payload: '{% if timeout is defined and timeout is number and timeout >= 0 %}{"timeout": {{ timeout | int }}}{% else %}{}{% endif %}' + payload: >- + {{ + { + 'timeout': timeout | int if timeout is defined and timeout is number and timeout >= 0 else none + } + | to_json + }} haoskiosk_display_off: url: "http://localhost:8080/display_off" @@ -496,13 +530,42 @@ rest_command: url: "http://localhost:8080/run_command" method: POST content_type: "application/json" - payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmd": "{{ cmd }}", "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmd": "{{ cmd }}"}{% endif %}' + payload: >- + {{ + { + 'cmd': cmd, + 'cmd_timeout': cmd_timeout | int if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 else none + } + | to_json + }} + haoskiosk_run_commands: url: "http://localhost:8080/run_commands" method: POST content_type: "application/json" - payload: '{% if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 %}{"cmds": {{ cmds | tojson }}, "cmd_timeout": {{ cmd_timeout | int }}}{% else %}{"cmds": {{ cmds | tojson }}}{% endif %}' + payload: >- + {{ + { + 'cmds': cmds, + 'cmd_timeout': cmd_timeout | int if cmd_timeout is defined and cmd_timeout is number and cmd_timeout > 0 else none + } + | to_json + }} + + haoskiosk_screenshot: + url: "http://localhost:8080/screenshot" + method: POST + content_type: "application/json" + payload: >- + {{ + { + 'delay': delay | int if delay is defined and delay | int(0) >= 0 else none, + 'filename': filename if filename is defined and filename != "" and "/" not in filename and "\0" not in filename else none, + 'quality': quality | int if quality is defined and 1 <= quality | int <= 100 else none + } + | to_json + }} haoskiosk_current_processes: url: "http://localhost:8080/current_processes" @@ -530,7 +593,13 @@ rest_command: url: "http://localhost:8080/unmute_audio" method: POST content_type: "application/json" - payload: '{"volume": "{{ volume }}"}' + payload: >- + {{ + { + 'volume': volume | int if volume is defined and volume is number and 0 <= volume | int <= 150 else none + } + | to_json + }} haoskiosk_toggle_audio: url: "http://localhost:8080/toggle_audio" @@ -845,13 +914,16 @@ ______________________________________________________________________ ## KEYBOARD SHORTCUTS -The following fixed keyboard shortcuts are defined (but subject to change). +The following new fixed keyboard shortcuts are defined (but subject to +change). + +- **Ctrl+o:** *Toggle Onboard onscreen keyboard* - **Ctrl+r:** *Reload page* -- **Ctrl+LeftArrow:** *Go back in the browser tab history* +- **Ctrl+Left:** *Go back in the browser tab history* -- **Ctrl+RightArrow:** *Go forward in the browser tab history* +- **Ctrl+Right:** *Go forward in the browser tab history* - **Ctrl+Alt+t:** *Open new tab* @@ -859,6 +931,18 @@ The following fixed keyboard shortcuts are defined (but subject to change). - **Ctrl+Alt+w:** *Open new window* +- **Ctl+Alt+Left:** *Previous tab* + +- **Ctl+Alt+Right:** *Next tab* + +- **Ctl+Alt+Shift+Left:** *Previous window* (Also: **Alt+Shift+Tab**) + +- **Ctl+Alt+Shift+Right:** *Next window* (Also: **Alt+Tab**) + +- **Ctrl+Alt+k:** *Take screenshot and save to /media/screenshots* + +Note that the Onbox Window manager defines many other default bindings. + ______________________________________________________________________ ## MISCELLANEOUS NOTES diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 4a6ede9..999c9ce 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test3" +version: "1.3.0-test4" slug: "haoskiosk" arch: @@ -61,6 +61,7 @@ devices: map: - addon_config:rw + - media:rw privileged: - SYS_ADMIN @@ -88,18 +89,20 @@ options: rest_ip: "127.0.0.1" rest_port: 8080 rest_bearer_token: "" - debug_mode: false gestures: - '"1_ANY_1_CORNER_TOPRIGHT": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' - '"[Left]_MOUSE_3_CLICK": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' - '"3_TOUCH_1_TAP": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' - '"3_TOUCH_2_TAP": {"cmds": "kiosk.refresh_browser", "msg": "Refresh Browser"}' - '"3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' + - '"3_TOUCH_4_TAP": {"cmds": "kiosk.screenshot", "msg": "Take screenshot"}' - '"3+_TOUCH_1_SWIPE_LEFT": {"cmds": "kiosk.forward", "msg": "Go forward in the history browser"}' - '"3+_TOUCH_1_SWIPE_RIGHT": {"cmds": "kiosk.back", "msg": "Go back in the history browser"}' - '"2_TOUCH_3_TAP": {"cmds": "kiosk.launch_url", "msg": "Restore default dashboard: HA_URL/HA_DASHBOARD"}' - '"2_TOUCH_4_TAP": {"cmds": [["kiosk.launch_url", "www.google.com"]], "msg": "Open Google search"}' command_whitelist: "cat|date|dbus-send|echo|false|grep|head|ls|luakit|notify-send|ping|ping6|ps|pstree|sleep|tail|test|top|tree|true|xdotool|xset" + vnc_server: "" + debug_mode: false schema: ha_username: str @@ -126,10 +129,11 @@ schema: rest_ip: str rest_port: int(1024,49151) rest_bearer_token: password - debug_mode: bool gestures: - str command_whitelist: str + vnc_server: password + debug_mode: bool translations: - en diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py index f175cae..0ee93fa 100755 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -158,14 +158,21 @@ def cleanup() -> None: date_time = datetime.now().strftime('%Y-%m-%d %H:%M') print() try: - if display is False: + if display_state() is False: display_on_print() # Turn display and audio back on... - if HA_INPUTS_TOGGLE is not None: + + binary_sensor_state = ha_binary_sensor_state(HA_BINARY_SENSOR) + if HA_INPUTS_TOGGLE is not None and binary_sensor_state == HA_INPUTS_TOGGLE: ha_disable_inputs(False) # Restore inputs print(f"[{date_time}] Restoring inputs...") - if ULTRASONIC_AUDIO is True or HA_AUDIO_TOGGLE is not None: + if (ULTRASONIC_AUDIO is True and display is False) or (HA_AUDIO_TOGGLE is not None and binary_sensor_state == HA_AUDIO_TOGGLE): ha_mute_audio(False) # Unmute audio print(f"[{date_time}] Restoring audio...") + if HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE: + new_url = ROTATE_URL_LIST[0] # Reset to first url + ha_launch_url(new_url) # Restore default (first) url + print(f"[{date_time}] Restoring: {new_url}") + gpio.close() except Exception as e: logger.error("Error: GPIO close failed (%s)", e) diff --git a/haoskiosk/mouse_touch_inputs.py b/haoskiosk/mouse_touch_inputs.py index ca03d2e..03b0e9e 100644 --- a/haoskiosk/mouse_touch_inputs.py +++ b/haoskiosk/mouse_touch_inputs.py @@ -364,6 +364,7 @@ import traceback import uuid from collections.abc import Hashable +from datetime import datetime from functools import wraps from typing import Any, cast, Callable, ClassVar, Final, Iterator, NotRequired, Protocol, Self, Sequence, Type, TypeAlias, TypedDict, TypeVar from Xlib import display #type: ignore[import-untyped] #pylint: disable=import-error @@ -634,17 +635,20 @@ def wrapper(*args: Any, **kwargs: Any) -> Any: @register_function("back") def handle_back(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: """Go back in browser history.""" - _run_subprocess(["xdotool", "key", "--clearmodifiers", "ctrl+Left"], timeout=timeout, description=_cmd_name) + cmd = ["xdotool", "key", "--clearmodifiers", "ctrl+Left"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("forward") def handle_forward(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: """Go forward in browser history.""" - _run_subprocess(["xdotool", "key", "--clearmodifiers", "ctrl+Right"], timeout=timeout, description=_cmd_name) + cmd = ["xdotool", "key", "--clearmodifiers", "ctrl+Right"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("refresh_browser") def handle_refresh_browser(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: """Reload current page.""" - _run_subprocess(["xdotool", "key", "--clearmodifiers", "ctrl+r"], timeout=timeout, description=_cmd_name) + cmd = ["xdotool", "key", "--clearmodifiers", "ctrl+r"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("launch_url", optional=["url"]) def handle_launch_url(url: str = DEFAULT_LAUNCH_URL, timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: @@ -657,7 +661,9 @@ def handle_launch_url(url: str = DEFAULT_LAUNCH_URL, timeout: int | None = None, raise ValueError(f"{_cmd_name}: Invalid URL format: {url}") if url != "about:blank" and not url.startswith(("http://", "https://")): url = "http://" + url - _run_subprocess(["luakit", "-n", url], timeout=timeout, description=_cmd_name) + + cmd = ["luakit", "-n", url] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("display_on", optional=["blank_timeout"], validators= @@ -683,17 +689,54 @@ def handle_display_on(blank_timeout: int | None = None, timeout: int | None = No @register_function("display_off") def handle_display_off(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: """Force display off immediately.""" - _run_subprocess(["xset", "dpms", "force", "off"], timeout=timeout, description=_cmd_name) + cmd = ["xset", "dpms", "force", "off"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) + +SCREENSHOT_DIR: str = "/media/screenshots" # Directory to store screenshots +@register_function("screenshot", optional=["filename", "quality", "delay"], + validators={ + "filename": lambda x: x is None or (x != "" and "\0" not in x and "/" not in x), + "quality": lambda x: x is None or (isinstance(x, int) and 1 <= x <= 100), + "delay": lambda x: x is None or (isinstance(x, int) and x >= 0), + }) +def handle_screenshot(filename: str | None = None, quality: int | None = None, delay: int | None = None, + timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: + """ + Take screen screenshot with optional filename, quality, and delay before screenshot + Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or .tiff + Quality only affects jpeg images + """ + if filename is None: + filename = f'haoskiosk-{datetime.now().strftime("%Y%m%d_%H%M%S")}' + if not filename.lower().endswith((".jpg", ".jpeg", ".bmp", ".png", ".pnm", ".tiff")): + filename += ".jpg" + + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + full_filename = os.path.join(SCREENSHOT_DIR, filename) + + if quality is None: + quality = 90 + + cmd = [ "scrot", full_filename, "-q", str(quality) ] + if delay is None: + delay = 0 + else: + cmd += [ "-d", str(delay), "-c" ] + + timeout = max(delay + 10, timeout or 0) + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("toggle_keyboard") def handle_toggle_keyboard(timeout: int | None = None, *, _cmd_name: str = "unknown") -> None: """Toggle onscreen keyboard.""" - _run_subprocess(["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"], timeout=timeout, description=_cmd_name) + cmd = ["dbus-send", "--type=method_call", "--dest=org.onboard.Onboard", "/org/onboard/Onboard/Keyboard", "org.onboard.Onboard.Keyboard.ToggleVisible"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) @register_function("toggle_audio") def handle_toggle_audio(timeout: int | None = None, *, _cmd_name: str = "toggle_audio") -> None: """Toggle mute state of the default audio sink.""" - _run_subprocess(["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"], timeout=timeout, description=_cmd_name) + cmd = ["pactl", "set-sink-mute", "@DEFAULT_SINK@", "toggle"] + _run_subprocess(cmd, timeout=timeout, description=_cmd_name) #------------------------------------------------------------------------------- #### Utility Functions diff --git a/haoskiosk/rest_server.py b/haoskiosk/rest_server.py index 46fedbc..d5803d9 100644 --- a/haoskiosk/rest_server.py +++ b/haoskiosk/rest_server.py @@ -11,10 +11,18 @@ GET /is_display_on POST /display_on (optional) {"timeout": } POST /display_off - GET /current_processes POST /xset {"args": "..."} + POST /screenshot (optional) {"filename: , + "quality": , + "delay": } + GET /current_processes POST /run_command {"cmd": "", "cmd_timeout": } POST /run_commands {"cmds": ["cmd1", "cmd2", ...], "cmd_timeout": } + POST /disable_inputs + POST /enable_inputs + POST /mute_audio + POST /unmute_audio (optional) {"timeout": } + POST /toggle_audio For security: - Defaults to listening only on 127.0.0.1 (localhost) @@ -47,6 +55,7 @@ import asyncio import inspect import ipaddress +import json import logging import os import re @@ -56,6 +65,7 @@ import subprocess import sys from contextlib import suppress +from datetime import datetime from functools import wraps from typing import Any, Awaitable, cast, Callable, Final, Literal, TypedDict, TypeVar from aiohttp import web #type: ignore[import-not-found] #pylint: disable=import-error @@ -518,6 +528,44 @@ async def handle_xset(data: Payload) -> dict[str, Any]: result = await execute_command(["xset"] + args_list, timeout=SHORT_TIMEOUT, log_prefix="xset", allow_command=True) return {"success": result["success"], "result": result} +SCREENSHOT_DIR: str = "/media/screenshots" # Directory to store screenshots +@register_function("screenshot", optional=["filename", "quality", "delay"], + validators={ + "filename": lambda x: x is None or (x != "" and "\0" not in x and "/" not in x), + "quality": lambda x: x is None or (isinstance(x, int) and 1 <= x <= 100), + "delay": lambda x: x is None or (isinstance(x, int) and x >= 0), + }) +async def handle_screenshot(data: Payload) -> dict[str, Any]: + """ + Take screen screenshot with optional filename, quality, and delay before screenshot + Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or .tiff + Quality only affects jpeg images + """ + filename = data.get("filename") + delay = data.get("delay") + quality = data.get("quality") + + if filename is None: + filename = f'haoskiosk-{datetime.now().strftime("%Y%m%d_%H%M%S")}' + if not filename.lower().endswith((".jpg", ".jpeg", ".bmp", ".png", ".pnm", ".tiff")): + filename += ".jpg" + + os.makedirs(SCREENSHOT_DIR, exist_ok=True) + full_filename = os.path.join(SCREENSHOT_DIR, filename) + + if quality is None: + quality = 90 + + cmd = [ "scrot", full_filename, "-q", str(quality) ] + if delay is None: + delay = 0 + else: + cmd += [ "-d", str(delay), "-c" ] + + logging.info("[screenshot] Saving screenshot to %s (quality=%d)%s", full_filename, quality, f" in {delay} seconds..." if delay else "") + result = await execute_command(cmd, timeout=delay + 10, log_prefix="screenshot", allow_command=True) + return {"success": result["success"], "result": result} + ### Commands and processes @register_function("current_processes") # GET endpoint async def handle_current_processes(data: Payload) -> dict[str, Any]: # pylint: disable=unused-argument @@ -560,7 +608,6 @@ async def handle_run_commands(data: Payload) -> dict[str, Any]: return {"success": all(r["success"] for r in results), "results": results} - ### Turn on/off inputs # List of inputs to skip when enabling/disabling inputs INPUT_IGNORE_LIST = [ "XTEST", "Power Button", "Video Bus", "Sleep Button", "Consumer Control", "System Control" ] @@ -799,19 +846,21 @@ async def security_middleware( If the token is missing or invalid → returns HTTP 401 immediately. Otherwise passes the request to the next handler. """ + remote_ip = request.remote or request.headers.get("X-Forwarded-For", "unknown").split(",")[0].strip() + logging.debug("[request] %s %s from %s", request.method, request.path, remote_ip) # Log every request for debug + if REST_BEARER_TOKEN: auth_header = request.headers.get("Authorization", "") if auth_header != f"Bearer {REST_BEARER_TOKEN}": - logging.warning("[auth] Invalid REST_BEARER_TOKEN from %s", request.remote or "unknown") + logging.warning("[auth] Invalid REST_BEARER_TOKEN from %s", remote_ip) return web.json_response( {"success": False, "error": "Invalid or missing REST_BEARER_TOKEN Authorization token"}, status=401,) cmd_name = getattr(handler, "cmd_name") if cmd_name in PROTECTED_COMMANDS: - remote_ip = request.remote or "unknown" if remote_ip not in ("127.0.0.1", "::1", "localhost") and REST_BEARER_TOKEN is None: - logging.warning("[security] Blocked protected REST command from %s: %s", remote_ip, cmd_name) + logging.warning("[security] Blocked protected REST command '%s' from non-localhost IP: %s", cmd_name, remote_ip) return web.json_response({ "success": False, "error": "Protected REST commands require localhost or bearer token" @@ -832,13 +881,22 @@ async def create_app() -> web.Application: route = f"/{fullname}" async def make_handler(request: web.Request, function: Callable[..., Any] = func, name: str = fullname) -> web.Response: - payload = await request.json() if request.can_read_body else {} + try: + payload = await request.json() if request.can_read_body else {} + except (json.JSONDecodeError, ValueError): # Malformed JSON from client + return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400) + try: result = await function(payload) + logging.debug("Handler success for %s from %s", name, request.remote) # Debug to avoid noise return web.json_response(result) + except (web.HTTPBadRequest, json.JSONDecodeError, ValueError) as e: + # Expected validation / input errors from registered functions + logging.debug("Handler error: validation error in %s: %s", name, str(e)) # Debug to avoid noise + return web.json_response({"success": False, "error": "Invalid JSON payload"}, status=400) except Exception as e: - logging.exception("Handler error: %s", name) - return web.json_response({"success": False, "error": str(e)}, status=500) + logging.exception("Handler error for %s: %s", name, str(e)) + return web.json_response({"success": False, "Internal server error": str(e)}, status=500) make_handler.cmd_name = fullname # type: ignore[attr-defined] diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index e194be0..5b9c094 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -34,6 +34,7 @@ # REST_IP # REST_BEARER_TOKEN # COMMAND_WHITELIST +# VNC_SERVER # DEBUG_MODE # # - Hack to delete (and later restore) /dev/tty0 (needed for X to start @@ -147,6 +148,7 @@ load_config_var REST_IP "127.0.0.1" load_config_var REST_BEARER_TOKEN "" 1 # Mask token in log load_config_var COMMAND_WHITELIST "^$" # Default is no commands allowed load_config_var DEBUG_MODE false +load_config_var VNC_SERVER "" 1 #Mask password in log # Validate environment variables set by config.yaml if [ -z "$HA_USERNAME" ] || [ -z "$HA_PASSWORD" ]; then @@ -367,6 +369,59 @@ fi #### Start Window manager in the background WINMGR=Openbox #Openbox window manager +## Change key bindings +mkdir -p ~/.config/openbox +RC_XML=~/.config/openbox/rc.xml +cp -a /etc/xdg/openbox/rc.xml "$RC_XML" +# Delete selected old key bindings +awk 'BEGIN{skip=0} //{skip=1} /<\/keybind>/ && skip{skip=0; next} !skip{print}' "$RC_XML" > /tmp/rc.new.xml +mv /tmp/rc.new.xml "$RC_XML" + +# Add new key bindings +cat <<'EOF' > /tmp/new_keybinds.xml + + + + dbus-send --type=method_call --dest=org.onboard.Onboard /org/onboard/Onboard/Keyboard org.onboard.Onboard.Keyboard.ToggleVisible + + + + + + + sh -c 'scrot /media/screenshots/haoskiosk-$(date +"%y%m%d_%H%M%S") -q 90' + + + + + + + + + + + + + + + + + + + + + + + + + +EOF +awk -v f=/tmp/new_keybinds.xml '/<\/keyboard>/ { system("cat " f) } { print }' \ + "$RC_XML" > /tmp/rc.new.xml +mv /tmp/rc.new.xml "$RC_XML" +rm /tmp/new_keybinds.xml + +# Start openbox openbox & #WINMGR=xfwm4 #Alternately using xfwm4 @@ -579,6 +634,35 @@ python3 -u /mouse_touch_inputs.py -d 1 -w "$COMMAND_WHITELIST" & bashio::log.info "Starting HAOSKiosk REST server..." python3 -u /rest_server.py & +#### Optionally start vnc server +if [ -n "$VNC_SERVER" ]; then + PRIMARY_DEV="$(ip route show | awk '/^default/ {print $5; exit}')" + HOST_IP="$(ip route show | sed -n "/\b${PRIMARY_DEV}\b/ s/.* src \([^ ]*\).*/\1/p" | head -1)" + VNC_PORT=5900 + + X11VNC_OPTS="-display :0 -rfbport $VNC_PORT -forever -bg -shared -quiet" + # Note caching and smoothing ("-ncache 10 -ncache_cr") not enabled since only works properly on some vnc viewers + + bashio::log.info "Starting x11vnc server $([[ "$VNC_SERVER" == "-" ]] && echo "WITHOUT" || echo "WITH") password on port $VNC_PORT. Access at: $HOST_IP:$VNC_PORT" + + if [ "$VNC_SERVER" != "-" ]; then # Use password + VNC_PASSWD_FILE="/root/x11vnc.pass" + + # Safely create obfuscated password file + printf '%s\n%s\ny\n' "${VNC_SERVER}" "${VNC_SERVER}" | x11vnc -storepasswd "$VNC_PASSWD_FILE" > /dev/null 2>&1 + chown root:root "$VNC_PASSWD_FILE" + chmod 600 "$VNC_PASSWD_FILE" + + X11VNC_OPTS="$X11VNC_OPTS -rfbauth $VNC_PASSWD_FILE" + + else # No password + X11VNC_OPTS="$X11VNC_OPTS -nopw" + fi + + # shellcheck disable=SC2086 + x11vnc $X11VNC_OPTS 2> >(grep -v 'The VNC desktop is:' >&2) +fi + #### Start browser (or debug mode) and wait/sleep if [ "$DEBUG_MODE" != true ]; then ### Run browser in the background and wait for process to exit diff --git a/haoskiosk/translations/en.yaml b/haoskiosk/translations/en.yaml index 5a403e2..2f007c2 100644 --- a/haoskiosk/translations/en.yaml +++ b/haoskiosk/translations/en.yaml @@ -114,11 +114,6 @@ configuration: Optional authorization token for REST API. [Default: ""] If set add the line `-H "Authorization: Bearer "` to your REST API calls. - debug_mode: - name: "Debug Mode" - description: | - Launch X and Openbox but not Luakit browser. [Default: False] - Manually access using: sudo docker -exec -it addon_haoskiosk bash gestures: name: Gesture Command List description: | @@ -129,3 +124,13 @@ configuration: description: | Regex of runnable user commands for the REST API and for use in gesture commands. See README for details + vnc_server: + name: "VNC Server Password" + description: | + Launch VNC Server on port 5900 if password non-blank. If password + set to '-', then don't require any password. *Use with caution* + debug_mode: + name: "Debug Mode" + description: | + Launch X and Openbox but not Luakit browser. [Default: False] + Manually access using: sudo docker -exec -it addon_haoskiosk bash diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index fcf0ebe..c0b10f0 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -12,8 +12,12 @@ Code does the following: - Auto login to Home Assistant using $HA_USERNAME and $HA_PASSWORD - Redefines key to return to normal mode (used for commands) from 'passthrough' mode to: 'Ctl+Alt+Esc' (rather than just 'Esc') to prevent unintended returns to normal mode and activation of unwanted commands - - Adds binding to reload browser screen (all modes) - - Adds and bindings, to move backwards and forwards respectively in the browser history + - Adds binding to reload browser screen (all modes) + - Adds and bindings, to move backwards and forwards respectively in the browser history + - Adds and bindings to move to previous and next tabs respectively + - Note and bindings move to previous and next windows (but defined in Openbox window manager bindings, not here + - Adds and for new and close tab respectively + - Adds for new window (note can't figure out yet how to kill window) - Prevent printing of '--PASS THROUGH--' status line when in 'passthrough' mode - Set up periodic browser refresh every $BROWSWER_REFRESH seconds (disabled if 0) NOTE: Original method injected JS to refresh page, now using native luakit view:reload command for more robustness @@ -463,6 +467,22 @@ webview.add_signal("init", function(view) end end) +-- ----------------------------------------------------------------------- +-- Define "circular" prev_tab_circ and next_tab_circ since prev_tab and next_tab are linear + +-- Focus next tab (circular) +local function next_tab_circ(w) + local idx = w.tabs:current() + local count = w.tabs:count() + w.tabs:switch((idx % count) + 1) +end + +-- Focus previous tab (circular) +local function prev_tab_circ(w) + local idx = w.tabs:current() + local count = w.tabs:count() + w.tabs:switch(((idx - 2) % count) + 1) +end -- ----------------------------------------------------------------------- -- Redefine to 'new_escape_key' (e.g., Ctl+Alt+Esc>) to exit current mode and enter normal mode @@ -498,12 +518,12 @@ modes.add_binds("all", { -- { "", "Close current window", function(w) w:close_window() end }, -- DOESN'T WORK -- Tab navigation --- { "", "Go to previous tab", function(w) w:prev_tab() end }, -- DOESN'T WORK --- { "", "Go tonext tab", function(w) w:next_tab() end }, -- DOESN'T WORK + { "", "Go to previous tab", function(w) w:prev_tab() end }, + { "", "Go to next tab", function(w) w:next_tab() end }, - -- Window navigation --- { "", "Focus previous window", function() window.focus_prev() end }, -- DOESN'T WORK --- { "", "Focus next window", function() window.focus_next() end }, -- DOESN'T WORK + -- Window navigation (Use Window manager bindings) + -- Ctrl+Alt+Shift+Left (or Shift+Alt+Tab) for "Go to previous window" + -- Ctrl+Alt+Sift+Right (or Alt+Tab) for "Go to next window" }) -- ----------------------------------------------------------------------- From 220c5f678e31cda2153e93278d53e492b2de856e Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 02:09:59 -0500 Subject: [PATCH 08/16] CHANGELOG edits --- haoskiosk/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index e1fc2bc..b65c443 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -2,8 +2,9 @@ ## v1.3.0 - February 2026 +- Added more key bindings - Add x11vnc server to facilitate remote viewing or debugging of kiosk -- Added 'screenshot' functoin to REST_API and gestue action commands +- Added 'screenshot' function to REST_API and gesture action commands - Added `enable_inputs` and `disable_inputs` functions to REST_API to allow locking down (and unlocking) inputs by disabling keyboard, mouse and touch functions From 5c836c950f7942417641377ade65c7058e2180d2 Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 02:26:44 -0500 Subject: [PATCH 09/16] - Bug fixes - README edits --- README.md | 6 ++++++ haoskiosk/README.md | 6 ++++++ haoskiosk/config.yaml | 2 +- haoskiosk/mouse_touch_inputs.py | 1 + haoskiosk/run.sh | 2 +- 5 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index be2417a..f79315b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ please file an \*\*include full details of your setup (including computer hardware and display type details)and what you did along with a complete log. +You can also use the `screenshot` REST API or keybinding (`Ctl+Alt+k`) or +touch gesture (Quadruple 3 finger tap) to save a snapshot to +`/media/screenshots` + ### If you appreciate my efforts: [![Buy Me a Coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://www.buymeacoffee.com/puterboy) @@ -418,6 +422,8 @@ screenshot. Quality only affects jpeg images Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or .tiff +Screenshots are saved to `/media/screenshots`. + Usage: ``` diff --git a/haoskiosk/README.md b/haoskiosk/README.md index be2417a..f79315b 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -42,6 +42,10 @@ please file an \*\*include full details of your setup (including computer hardware and display type details)and what you did along with a complete log. +You can also use the `screenshot` REST API or keybinding (`Ctl+Alt+k`) or +touch gesture (Quadruple 3 finger tap) to save a snapshot to +`/media/screenshots` + ### If you appreciate my efforts: [![Buy Me a Coffee](https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png)](https://www.buymeacoffee.com/puterboy) @@ -418,6 +422,8 @@ screenshot. Quality only affects jpeg images Output format is jpeg unless optional filename ends in .bmp, .png, .pnm, or .tiff +Screenshots are saved to `/media/screenshots`. + Usage: ``` diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 999c9ce..b1b856c 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test4" +version: "1.3.0-test5" slug: "haoskiosk" arch: diff --git a/haoskiosk/mouse_touch_inputs.py b/haoskiosk/mouse_touch_inputs.py index 03b0e9e..b666fbc 100644 --- a/haoskiosk/mouse_touch_inputs.py +++ b/haoskiosk/mouse_touch_inputs.py @@ -714,6 +714,7 @@ def handle_screenshot(filename: str | None = None, quality: int | None = None, d os.makedirs(SCREENSHOT_DIR, exist_ok=True) full_filename = os.path.join(SCREENSHOT_DIR, filename) + if quality is None: quality = 90 diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index 5b9c094..5d89af7 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -389,7 +389,7 @@ cat <<'EOF' > /tmp/new_keybinds.xml - sh -c 'scrot /media/screenshots/haoskiosk-$(date +"%y%m%d_%H%M%S") -q 90' + sh -c 'scrot /media/screenshots/haoskiosk-$(date +"%Y%m%d_%H%M%S").jpg -q 90' From 093dbf845a856fcaf232c38907c91e702270cf59 Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 02:27:39 -0500 Subject: [PATCH 10/16] - Edits --- haoskiosk/mouse_touch_inputs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/haoskiosk/mouse_touch_inputs.py b/haoskiosk/mouse_touch_inputs.py index b666fbc..03b0e9e 100644 --- a/haoskiosk/mouse_touch_inputs.py +++ b/haoskiosk/mouse_touch_inputs.py @@ -714,7 +714,6 @@ def handle_screenshot(filename: str | None = None, quality: int | None = None, d os.makedirs(SCREENSHOT_DIR, exist_ok=True) full_filename = os.path.join(SCREENSHOT_DIR, filename) - if quality is None: quality = 90 From 1a903bf7b4a47746ac38539121663396fea79985 Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 02:32:46 -0500 Subject: [PATCH 11/16] README edit --- README.md | 2 +- haoskiosk/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f79315b..eb0f5a6 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ please file an display type details)and what you did along with a complete log. You can also use the `screenshot` REST API or keybinding (`Ctl+Alt+k`) or -touch gesture (Quadruple 3 finger tap) to save a snapshot to +touch gesture (Quadruple 3 finger tap) to save a screenshot to `/media/screenshots` ### If you appreciate my efforts: diff --git a/haoskiosk/README.md b/haoskiosk/README.md index f79315b..eb0f5a6 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -43,7 +43,7 @@ please file an display type details)and what you did along with a complete log. You can also use the `screenshot` REST API or keybinding (`Ctl+Alt+k`) or -touch gesture (Quadruple 3 finger tap) to save a snapshot to +touch gesture (Quadruple 3 finger tap) to save a screenshot to `/media/screenshots` ### If you appreciate my efforts: From 02792bbefcd6d43955e23aa651e5ff7b4b7e8dd6 Mon Sep 17 00:00:00 2001 From: puterboy Date: Fri, 30 Jan 2026 03:06:01 -0500 Subject: [PATCH 12/16] - Added window close binding --- README.md | 8 ++++++++ haoskiosk/CHANGELOG.md | 2 +- haoskiosk/README.md | 8 ++++++++ haoskiosk/config.yaml | 4 ++-- haoskiosk/userconf.lua | 25 +++++++++++-------------- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index eb0f5a6..04b56bc 100644 --- a/README.md +++ b/README.md @@ -868,6 +868,12 @@ clicking on the `X` next to them): "3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' ``` +- **3-Finger Quadruple Tap**: *Save screenshot* + +``` +"3_TOUCH_4_TAP": {"cmds": "kiosk.screenshot", "msg": "Save screenshot"}' +``` + - **3-Finger Left Swipe**: *Go forward one element in browser history* ``` @@ -937,6 +943,8 @@ change). - **Ctrl+Alt+w:** *Open new window* +- **Ctrl+Alt+Shift+w:** *Close current window* (except for last window) + - **Ctl+Alt+Left:** *Previous tab* - **Ctl+Alt+Right:** *Next tab* diff --git a/haoskiosk/CHANGELOG.md b/haoskiosk/CHANGELOG.md index b65c443..a2a7cc7 100644 --- a/haoskiosk/CHANGELOG.md +++ b/haoskiosk/CHANGELOG.md @@ -2,7 +2,7 @@ ## v1.3.0 - February 2026 -- Added more key bindings +- Added more key bindings for opening/closing/rotating tabs and windows - Add x11vnc server to facilitate remote viewing or debugging of kiosk - Added 'screenshot' function to REST_API and gesture action commands - Added `enable_inputs` and `disable_inputs` functions to REST_API to allow diff --git a/haoskiosk/README.md b/haoskiosk/README.md index eb0f5a6..04b56bc 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -868,6 +868,12 @@ clicking on the `X` next to them): "3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' ``` +- **3-Finger Quadruple Tap**: *Save screenshot* + +``` +"3_TOUCH_4_TAP": {"cmds": "kiosk.screenshot", "msg": "Save screenshot"}' +``` + - **3-Finger Left Swipe**: *Go forward one element in browser history* ``` @@ -937,6 +943,8 @@ change). - **Ctrl+Alt+w:** *Open new window* +- **Ctrl+Alt+Shift+w:** *Close current window* (except for last window) + - **Ctl+Alt+Left:** *Previous tab* - **Ctl+Alt+Right:** *Next tab* diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index b1b856c..c82c50a 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test5" +version: "1.3.0-test6" slug: "haoskiosk" arch: @@ -95,7 +95,7 @@ options: - '"3_TOUCH_1_TAP": {"cmds": "kiosk.toggle_keyboard", "msg": "Toggling Onboard keyboard..."}' - '"3_TOUCH_2_TAP": {"cmds": "kiosk.refresh_browser", "msg": "Refresh Browser"}' - '"3_TOUCH_3_TAP": {"cmds": "kiosk.toggle_audio", "msg": "Toggle audio mute"}' - - '"3_TOUCH_4_TAP": {"cmds": "kiosk.screenshot", "msg": "Take screenshot"}' + - '"3_TOUCH_4_TAP": {"cmds": "kiosk.screenshot", "msg": "Save screenshot"}' - '"3+_TOUCH_1_SWIPE_LEFT": {"cmds": "kiosk.forward", "msg": "Go forward in the history browser"}' - '"3+_TOUCH_1_SWIPE_RIGHT": {"cmds": "kiosk.back", "msg": "Go back in the history browser"}' - '"2_TOUCH_3_TAP": {"cmds": "kiosk.launch_url", "msg": "Restore default dashboard: HA_URL/HA_DASHBOARD"}' diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index c0b10f0..187ccb8 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -17,7 +17,7 @@ Code does the following: - Adds and bindings to move to previous and next tabs respectively - Note and bindings move to previous and next windows (but defined in Openbox window manager bindings, not here - Adds and for new and close tab respectively - - Adds for new window (note can't figure out yet how to kill window) + - Adds for new and close window - Prevent printing of '--PASS THROUGH--' status line when in 'passthrough' mode - Set up periodic browser refresh every $BROWSWER_REFRESH seconds (disabled if 0) NOTE: Original method injected JS to refresh page, now using native luakit view:reload command for more robustness @@ -468,20 +468,16 @@ webview.add_signal("init", function(view) end end) -- ----------------------------------------------------------------------- --- Define "circular" prev_tab_circ and next_tab_circ since prev_tab and next_tab are linear +-- Tab and Window functions --- Focus next tab (circular) -local function next_tab_circ(w) - local idx = w.tabs:current() - local count = w.tabs:count() - w.tabs:switch((idx % count) + 1) -end --- Focus previous tab (circular) -local function prev_tab_circ(w) - local idx = w.tabs:current() - local count = w.tabs:count() - w.tabs:switch(((idx - 2) % count) + 1) +-- Close window unless last window (to avoid quitting luakit) +local function close_win_not_last(w) + if #luakit.windows > 1 then + w:close_win() + else + msg.warn("WARNING: This is the last window — not closing.") + end end -- ----------------------------------------------------------------------- @@ -515,7 +511,8 @@ modes.add_binds("all", { { "", "Open new tab", function(w) w:new_tab("about:blank") end }, { "", "Close current tab", function(w) w:close_tab() end }, { "", "Open new window", function() window.new() end }, --- { "", "Close current window", function(w) w:close_window() end }, -- DOESN'T WORK +-- { "", "Close current window", function(w) w:close_win() end }, + { "", "Close current window", function(w) close_win_not_last(w) end }, -- Tab navigation { "", "Go to previous tab", function(w) w:prev_tab() end }, From c9409b363ef8df5c5001bc31f793623a2fc4c7a5 Mon Sep 17 00:00:00 2001 From: puterboy Date: Sat, 31 Jan 2026 18:22:19 -0500 Subject: [PATCH 13/16] - Default luakit new tab to about:blank - Ultrasonic example fix --- haoskiosk/examples/ultrasonic-trigger.py | 10 +++++----- haoskiosk/run.sh | 4 ++-- haoskiosk/userconf.lua | 7 ++++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py index 0ee93fa..9cff3df 100755 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -113,13 +113,13 @@ #=============================================================================== ### Setup +if not ROTATE_URL_LIST: + HA_ROTATE_TOGGLE = False + if HA_BINARY_SENSOR_FRIENDLY_NAME is None and HA_BINARY_SENSOR is not None: #Get string after last '.', replace '_' with space, capitalize words HA_BINARY_SENSOR_FRIENDLY_NAME = HA_BINARY_SENSOR.rsplit('.', 1)[-1].replace('_', ' ').title() -if not ROTATE_URL_LIST: - HA_ROTATE_TOGGLE = None - #Relaunch 'unbuffered' if not already unbuffered so that you can pipe output real-time if desired if os.environ.get('PYTHONUNBUFFERED') != '1': os.environ['PYTHONUNBUFFERED'] = '1' @@ -494,8 +494,8 @@ def main()-> None: date_time = datetime.now().strftime('%Y-%m-%d %H:%M') print(f"[{date_time}] Restoring: {new_url}") - if not binary_sensor_state: - display_state_print() # Set and show display state every 60 seconds + if not binary_sensor_state and not loop_num % 300: + display_state_print() # Set and show display state every 300 seconds if display is True and HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE and not loop_num % ROTATE_FREQ: # Rotate url new_url = ROTATE_URL_LIST[(loop_num // ROTATE_FREQ) % len(ROTATE_URL_LIST)] diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index 5d89af7..7ce7ed8 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -636,8 +636,8 @@ python3 -u /rest_server.py & #### Optionally start vnc server if [ -n "$VNC_SERVER" ]; then - PRIMARY_DEV="$(ip route show | awk '/^default/ {print $5; exit}')" - HOST_IP="$(ip route show | sed -n "/\b${PRIMARY_DEV}\b/ s/.* src \([^ ]*\).*/\1/p" | head -1)" + PRIMARY_DEV="$(ip route show | awk '/^default/ {print $5; exit}')" # Returns name of primary device (typically Ethernet before WiFi) + HOST_IP="$(ip route show | sed -n "/\b${PRIMARY_DEV}\b/ s/.* src \([^ ]*\).*/\1/p" | head -1)" # Return first IP address tied to primary device VNC_PORT=5900 X11VNC_OPTS="-display :0 -rfbport $VNC_PORT -forever -bg -shared -quiet" diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index 187ccb8..8899030 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -8,6 +8,7 @@ Date: February 2026 Code does the following: - Sets browser window to fullscreen - Sets zooms level to value of $ZOOM_LEVEL (default 100%) + - Sets new tab and window default to blank page (about:blank) - Loads every URL in 'passthrough' mode so that you can type text as needed without triggering browser commands - Auto login to Home Assistant using $HA_USERNAME and $HA_PASSWORD - Redefines key to return to normal mode (used for commands) from 'passthrough' mode to: 'Ctl+Alt+Esc' @@ -148,6 +149,10 @@ end) -- Set zoom level for windows (default 100%) settings.webview.zoom_level = zoom_level +-- Set default new tab and window to blank page, rather than commercial luakit page +settings.window.home_page = "about:blank" +settings.window.new_tab_page = "about:blank" + -- Prevent session restore by overloading 'session.restore' local session = require "session" session.restore = function() @@ -508,7 +513,7 @@ modes.add_binds("all", { { "", "Go forward in the browser history", function(w, m) w:forward(m.count) end }, -- New/Close tab and window - { "", "Open new tab", function(w) w:new_tab("about:blank") end }, + { "", "Open new tab", function(w) w:new_tab() end }, { "", "Close current tab", function(w) w:close_tab() end }, { "", "Open new window", function() window.new() end }, -- { "", "Close current window", function(w) w:close_win() end }, From 8d8b8a65c82d118dd92c94fb4bdf10f921333c2c Mon Sep 17 00:00:00 2001 From: puterboy Date: Sun, 1 Feb 2026 22:05:54 -0500 Subject: [PATCH 14/16] - Ultrasonic example bug fixes and improvements --- haoskiosk/config.yaml | 2 +- haoskiosk/examples/ultrasonic-trigger.py | 107 +++++++++++++---------- 2 files changed, 62 insertions(+), 47 deletions(-) diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index c82c50a..590a1bc 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test6" +version: "1.3.0-test7" slug: "haoskiosk" arch: diff --git a/haoskiosk/examples/ultrasonic-trigger.py b/haoskiosk/examples/ultrasonic-trigger.py index 9cff3df..7d80618 100755 --- a/haoskiosk/examples/ultrasonic-trigger.py +++ b/haoskiosk/examples/ultrasonic-trigger.py @@ -46,7 +46,7 @@ # Ignore if None # - If HA_INPUTS_TOGGLE is True/False then disable inputs when HA_BINARY_SENSOR is on/off; # Ignore if None -# - If HA_ROTATE_URLS is True/False then rotate urls when HA_BINARY_SENSOR is on/off +# - If HA_ROTATE_TOGGLE is True/False then rotate urls when HA_BINARY_SENSOR is on/off # Ignore if None # This can be used to make the display, input, and audio states depend on the # on/off state of the HA_BINARY_SENSOR sensor @@ -113,7 +113,11 @@ #=============================================================================== ### Setup -if not ROTATE_URL_LIST: + +current_url: str | None = None +if ROTATE_URL_LIST: # Non-empty list + current_url = ROTATE_URL_LIST[0] +else: # Empty rotate list so turn off rotation HA_ROTATE_TOGGLE = False if HA_BINARY_SENSOR_FRIENDLY_NAME is None and HA_BINARY_SENSOR is not None: @@ -155,23 +159,23 @@ def handle_exit(_signum: int, _frame: types.FrameType | None) -> None: def cleanup() -> None: """Cleanup before exiting...""" - date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + date_time = get_datetime() print() try: if display_state() is False: display_on_print() # Turn display and audio back on... - binary_sensor_state = ha_binary_sensor_state(HA_BINARY_SENSOR) - if HA_INPUTS_TOGGLE is not None and binary_sensor_state == HA_INPUTS_TOGGLE: - ha_disable_inputs(False) # Restore inputs - print(f"[{date_time}] Restoring inputs...") - if (ULTRASONIC_AUDIO is True and display is False) or (HA_AUDIO_TOGGLE is not None and binary_sensor_state == HA_AUDIO_TOGGLE): + if current_mute is True: ha_mute_audio(False) # Unmute audio - print(f"[{date_time}] Restoring audio...") - if HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE: - new_url = ROTATE_URL_LIST[0] # Reset to first url - ha_launch_url(new_url) # Restore default (first) url - print(f"[{date_time}] Restoring: {new_url}") + print(f"[{date_time}] Unmuting audio...") + + if current_inputs_disabled is True: + ha_disable_inputs(False) # Restore inputs + print(f"[{date_time}] Enabling inputs...") + + if current_url is not None and current_url != ROTATE_URL_LIST[0]: + ha_launch_url(ROTATE_URL_LIST[0]) # Restore default (first) url + print(f"[{date_time}] Restoring URL: {ROTATE_URL_LIST[0]}") gpio.close() except Exception as e: @@ -248,6 +252,10 @@ def measure_distance() -> float | None: invalid_count = 0 # Reset invalid counter return sum(distances) / len(distances) if distances else None +def get_datetime() -> str: + """Return time string in format: YY-MM-DD HH:MM:SS""" + return datetime.now().strftime('%Y-%m-%d %H:%M:%S') + #=============================================================================== ### HAOKiosk Api calls @@ -267,7 +275,7 @@ def display_state() -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False): + if not data.get("success", False): # Failed to get display state logger.error("Failed to get display state") return False return data["display_on"] is True @@ -275,15 +283,16 @@ def display_state() -> bool: logger.error("HTTPRequest failed (%s)", e) return False +current_display: bool | None = None # Start in unknown state def display_state_print() -> None: """Print display state""" - global display + global current_display try: - display = display_state() - if display is True: - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is ON") + current_display = display_state() + if current_display is True: + print(f"[{get_datetime()}] Display is ON") else: - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] Display is OFF") + print(f"[{get_datetime()}] Display is OFF") except (requests.RequestException, ValueError) as e: logger.error("Display is INVALID (%s)", e) @@ -298,8 +307,8 @@ def display_on() -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False): - logger.error("Failed to get display state") + if not data.get("success", False): # Failed to turn on display + logger.error("Failed to turn on display") return False return True except (requests.RequestException, ValueError) as e: @@ -317,7 +326,8 @@ def display_off() -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False): + if not data.get("success", False): # Failed to turn off display + logger.error("Failed to turn on display") return False return True except (requests.RequestException, ValueError) as e: @@ -338,9 +348,9 @@ def display_on_print(audio_too: bool=False) -> None: if audio_too: ha_mute_audio(False) # Also umute audio msg = " and restoring audio" - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display ON{msg}*** (Duration: {display_time_diff})") - global display - display = True + print(f"[{get_datetime()}] ***Turning display ON{msg}*** (Duration: {display_time_diff})") + global current_display + current_display = True else: logger.error("FAILED to turn display ON") @@ -357,9 +367,9 @@ def display_off_print(audio_too: bool=False) ->None: if audio_too: ha_mute_audio(True) # Also mute audio msg = " and muting audio" - print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M')}] ***Turning display OFF{msg}*** (Duration: {display_time_diff})") - global display - display = False + print(f"[{get_datetime()}] ***Turning display OFF{msg}*** (Duration: {display_time_diff})") + global current_display + current_display = False else: logger.error("FAILED to turn display OFF") @@ -387,6 +397,7 @@ def ha_binary_sensor_state(sensor: str | None) -> bool | None: logger.error("HTTP Request failed (%s)", e) return None +current_inputs_disabled: bool | None = None # Start in unknown state def ha_disable_inputs(state: bool) -> bool: """Disable/enable inputs""" if state: @@ -401,14 +412,18 @@ def ha_disable_inputs(state: bool) -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False): + if not data.get("success", False): # Failed to enable/disable inputs + logger.error("Failed to %s inputs", {"disable" if state else "enable"}) return False + global current_inputs_disabled + current_inputs_disabled = state return True except (requests.RequestException, ValueError): return False +current_mute: bool | None = None # Start in unknown state def ha_mute_audio(state: bool) -> bool: - """Mute/unmute audio""" + """Mute/unmute audio. Return True on success""" if state: url = f"http://localhost:{REST_PORT}/mute_audio" else: @@ -421,8 +436,11 @@ def ha_mute_audio(state: bool) -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False): + if not data.get("success", False): # Failed to mute/unmute audio + logger.error("Failed to %s audio", {"mute" if state else "unmute"}) return False + global current_mute + current_mute = state return True except (requests.RequestException, ValueError): return False @@ -438,7 +456,7 @@ def ha_launch_url(site: str) -> bool: ) response.raise_for_status() data = response.json() - if not data.get("success", False) or not data.get("result", {}).get("success", False): + if not data.get("success", False) or not data.get("result", {}).get("success", False): # Failed to launch url logging.debug("Failed to launch_url: %s", url) return False stdout_text = data["result"].get("stdout", "") @@ -450,7 +468,6 @@ def ha_launch_url(site: str) -> bool: #=============================================================================== ### Main loop -display = False def main()-> None: """Main event loop""" @@ -465,6 +482,7 @@ def main()-> None: count = 0 binary_sensor_state = None + global current_url # Main event loop while True: loop_start = time.monotonic() @@ -474,12 +492,11 @@ def main()-> None: old_binary_sensor_state = binary_sensor_state binary_sensor_state = ha_binary_sensor_state(HA_BINARY_SENSOR) if binary_sensor_state is not None and binary_sensor_state != old_binary_sensor_state: # Status of binary_sensor_state changed - date_time = datetime.now().strftime('%Y-%m-%d %H:%M') + date_time = get_datetime() print(f"[{date_time}] '{HA_BINARY_SENSOR_FRIENDLY_NAME}' = {binary_sensor_state}") if HA_DISPLAY_TOGGLE is not None: if binary_sensor_state == HA_DISPLAY_TOGGLE: display_on_print(audio_too=ULTRASONIC_AUDIO and HA_AUDIO_TOGGLE is None) # Turn on display (because need to keep it always on) - date_time = datetime.now().strftime('%Y-%m-%d %H:%M') if HA_INPUTS_TOGGLE is not None: state = binary_sensor_state == HA_INPUTS_TOGGLE ha_disable_inputs(state) @@ -489,19 +506,17 @@ def main()-> None: ha_mute_audio(state) print(f"[{date_time}] ***{"Muting" if state else "Unmuting"} audio***") if HA_ROTATE_TOGGLE is not None and binary_sensor_state != HA_ROTATE_TOGGLE: # Reset to first url - new_url = ROTATE_URL_LIST[0] - ha_launch_url(new_url) # Restore default (first) url - date_time = datetime.now().strftime('%Y-%m-%d %H:%M') - print(f"[{date_time}] Restoring: {new_url}") + current_url = ROTATE_URL_LIST[0] + ha_launch_url(current_url) # Restore default (first) url + print(f"[{date_time}] Restoring: {current_url}") if not binary_sensor_state and not loop_num % 300: display_state_print() # Set and show display state every 300 seconds - if display is True and HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE and not loop_num % ROTATE_FREQ: # Rotate url - new_url = ROTATE_URL_LIST[(loop_num // ROTATE_FREQ) % len(ROTATE_URL_LIST)] - ha_launch_url(new_url) - date_time = datetime.now().strftime('%Y-%m-%d %H:%M') - print(f"[{date_time}] Rotating url: {new_url}") + if current_display is True and HA_ROTATE_TOGGLE is not None and binary_sensor_state == HA_ROTATE_TOGGLE and not loop_num % ROTATE_FREQ: # Rotate url + current_url = ROTATE_URL_LIST[(loop_num // ROTATE_FREQ) % len(ROTATE_URL_LIST)] + ha_launch_url(current_url) + print(f"[{get_datetime()}] Rotating url: {current_url}") if HA_DISPLAY_TOGGLE is not None and binary_sensor_state == HA_DISPLAY_TOGGLE: # Avoid calculating distance & turning on/off display time.sleep(LOOP_TIME) @@ -514,12 +529,12 @@ def main()-> None: if distance < NEAR_ON_DIST: count = max(count, 0) count += 1 - if display is False and count >= COUNT_ON_THRESH: + if current_display is False and count >= COUNT_ON_THRESH: display_on_print(audio_too=ULTRASONIC_AUDIO) # Turn ON display elif distance > FAR_OFF_DIST: count = min(count, 0) count -= 1 - if display is True and count <= -COUNT_OFF_THRESH: + if current_display is True and count <= -COUNT_OFF_THRESH: display_off_print(audio_too=ULTRASONIC_AUDIO) # Turn OFF display else: print("Distance: Invalid") From f626969fdb11aae7fb45c66f0ebacb6d465366db Mon Sep 17 00:00:00 2001 From: puterboy Date: Tue, 17 Feb 2026 13:21:52 -0500 Subject: [PATCH 15/16] - Bug fixes and tweaks --- README.md | 11 ++++++++--- haoskiosk/README.md | 11 ++++++++--- haoskiosk/run.sh | 1 + haoskiosk/userconf.lua | 6 +++++- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 04b56bc..006eba7 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,14 @@ output device connected. If so, use the logs to see how they are numbered. ### Dark Mode Prefer dark mode where supported if `True`, otherwise prefer light mode. -(Default: True) - -NOTE: This applies to *all* url's. +(Default: True). This preference applies to all URLs + +NOTE: This preference applies to all URLs unless overriden in the URL. In +particular, in Home Assistant web pages, This preference for light or dark +mode only takes effect if the user profile (under 'Theme') is set to +`auto`. Otherwise, the user profile `light` or `dark` setting takes +precedence. Similarly, the `Primary` and `Accent` colors set in the profile +take precedence *unless* `HA Theme` is set. ### HA Theme diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 04b56bc..006eba7 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -144,9 +144,14 @@ output device connected. If so, use the logs to see how they are numbered. ### Dark Mode Prefer dark mode where supported if `True`, otherwise prefer light mode. -(Default: True) - -NOTE: This applies to *all* url's. +(Default: True). This preference applies to all URLs + +NOTE: This preference applies to all URLs unless overriden in the URL. In +particular, in Home Assistant web pages, This preference for light or dark +mode only takes effect if the user profile (under 'Theme') is set to +`auto`. Otherwise, the user profile `light` or `dark` setting takes +precedence. Similarly, the `Primary` and `Accent` colors set in the profile +take precedence *unless* `HA Theme` is set. ### HA Theme diff --git a/haoskiosk/run.sh b/haoskiosk/run.sh index 7ce7ed8..d750f8c 100755 --- a/haoskiosk/run.sh +++ b/haoskiosk/run.sh @@ -75,6 +75,7 @@ cleanup() { fi jobs -p | xargs -r kill [ -n "$TTY0_DELETED" ] && mknod -m 620 /dev/tty0 c 4 0 + rm -f /root/.local/share/luakit/cookies.db # Remove cookie storage (not really necessary, but just in case...) exit "$exit_code" } trap cleanup HUP INT QUIT ABRT TERM EXIT diff --git a/haoskiosk/userconf.lua b/haoskiosk/userconf.lua index 8899030..7b06197 100644 --- a/haoskiosk/userconf.lua +++ b/haoskiosk/userconf.lua @@ -306,6 +306,7 @@ webview.add_signal("init", function(view) // Set theme if specified const theme = '%s'; const currentTheme = localStorage.getItem('selectedTheme') || ''; + if (theme !== currentTheme) { if (theme !== "") { localStorage.setItem('selectedTheme', theme); @@ -313,8 +314,11 @@ webview.add_signal("init", function(view) localStorage.removeItem('selectedTheme'); } } +// console.log("Setting sidebar: " + currentSidebar + " -> " + sidebar + " [Result=" + localStorage.getItem('dockedSidebar') + +// "]; theme: " + currentTheme + " -> " + theme + " [Result=" + localStorage.getItem('selectedTheme') + "]"); // DEBUG -// localStorage.setItem('DebugLog', "Setting sidebar: " + currentSidebar + " -> " + sidebar + "; theme: " + currentTheme + " -> " + theme); // DEBUG +// localStorage.setItem('DebugLog', "Setting sidebar: " + currentSidebar + " -> " + sidebar + " [Result=" + localStorage.getItem('dockedSidebar') + +// "]; theme: " + currentTheme + " -> " + theme + "[Result=" + localStorage.getItem('selectedTheme') + "]"); // DEBUG } catch (err) { console.error(err); console.log("FAILED to set: Sidebar: " + sidebar + " Theme: " + theme + " [" + err + "]"); // DEBUG From f8c0c046443260853f2859bf3d5d2ab90d320785 Mon Sep 17 00:00:00 2001 From: puterboy Date: Tue, 17 Feb 2026 13:34:54 -0500 Subject: [PATCH 16/16] - Updated version to 1.3.0 (release) - README.md typo --- README.md | 2 +- haoskiosk/README.md | 2 +- haoskiosk/config.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 006eba7..c9827c8 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ output device connected. If so, use the logs to see how they are numbered. Prefer dark mode where supported if `True`, otherwise prefer light mode. (Default: True). This preference applies to all URLs -NOTE: This preference applies to all URLs unless overriden in the URL. In +NOTE: This preference applies to all URLs unless overridden in the URL. In particular, in Home Assistant web pages, This preference for light or dark mode only takes effect if the user profile (under 'Theme') is set to `auto`. Otherwise, the user profile `light` or `dark` setting takes diff --git a/haoskiosk/README.md b/haoskiosk/README.md index 006eba7..c9827c8 100644 --- a/haoskiosk/README.md +++ b/haoskiosk/README.md @@ -146,7 +146,7 @@ output device connected. If so, use the logs to see how they are numbered. Prefer dark mode where supported if `True`, otherwise prefer light mode. (Default: True). This preference applies to all URLs -NOTE: This preference applies to all URLs unless overriden in the URL. In +NOTE: This preference applies to all URLs unless overridden in the URL. In particular, in Home Assistant web pages, This preference for light or dark mode only takes effect if the user profile (under 'Theme') is set to `auto`. Otherwise, the user profile `light` or `dark` setting takes diff --git a/haoskiosk/config.yaml b/haoskiosk/config.yaml index 590a1bc..34deb34 100644 --- a/haoskiosk/config.yaml +++ b/haoskiosk/config.yaml @@ -3,7 +3,7 @@ name: "HAOS Kiosk Display" description: | Start X server and browser on local HAOS server and display dashboards in kiosk mode (Jeff Kosowsky) -version: "1.3.0-test7" +version: "1.3.0" slug: "haoskiosk" arch: