#!/usr/bin/env bash # SPDX-License-Identifier: GPL-3.0-or-later #:########################################################################################:# # HA Kiosk MQTT Control Script # #:########################################################################################:# # # TITLE: # HA Kiosk MQTT Control # # VERSION: # V3.0 - 2026-09-26 # # PURPOSE: # Provides local MQTT control and state reporting for a Linux touchscreen kiosk. # # CONTROLS: # - Four persistent display modes using one MQTT select. # - Saved current brightness and physical backlight brightness. # - Temporary presence brightness from touch or camera motion. # - Shared touch and presence timeout using a ten-step brightness ramp. # - Speaker volume and mute using PipeWire wpctl. # - Actual screen, screensaver, brightness, and presence state reporting. # # DISPLAY MODES: # Screen Lock: # - Disables touchscreen input. # - Stops XScreenSaver. # - Keeps the physical display off. # - Ignores touch and camera-presence activity. # # Screen Off: # - Keeps the touchscreen enabled. # - Keeps the physical display off while presence is inactive. # - Touch always wakes the display. # - Camera motion wakes the display when Motion Wake is enabled. # - Returns to Screen Off after the presence ramp reaches Current Brightness. # # Screensaver: # - Starts XScreenSaver immediately at Current Brightness. # - Touch stops the screensaver and activates Presence Brightness. # - Camera motion does the same when Motion Wake is enabled. # - Returns to XScreenSaver at Current Brightness after the presence ramp. # # Screen On: # - Keeps the display on at Current Brightness while presence is inactive. # - Touch activates Presence Brightness. # - Camera motion does the same when Motion Wake is enabled. # - Returns to Current Brightness after the presence ramp. # # PRESENCE OPERATION: # - Touch and camera motion share the same Presence Timeout. # - Touch always counts as activity except in Screen Lock mode. # - Camera motion counts as activity only when Motion Wake is enabled. # - Activity turns the display on and applies Presence Brightness immediately. # - A touch starts or restarts the ten-step brightness ramp immediately unless # qualifying camera motion is currently holding presence active. # - Qualifying camera motion holds presence active until motion ends. # - The latest saved Current Brightness is used as the ramp target. # - The selected display mode determines what happens after the final ramp step. # - A Presence End MQTT command can run a one-off ramp without changing the # saved Presence Timeout. # # MQTT AUTHENTICATION: # Configure Mosquitto client defaults securely on this host. This script uses # the installed mosquitto_pub and mosquitto_sub client configuration. # # VERSION HISTORY: # V3.0 - 2026-09-26 # - Parameterized kiosk identity, MQTT roots, display settings and service names. # - Added periodic retained-state refresh and an MQTT subscriber heartbeat watchdog. # - Kept the browser in an independent restartable systemd service. # - Added a Presence End MQTT command for one-off presence ramps. # # V2.0 - 2026-07-28 # - Replaced separate Screen and Screensaver command switches with one # persistent four-option Display Mode control. # - Added Screen Lock, Screen Off, Screensaver, and Screen On modes. # - Added direct touchscreen activity detection using XInput RawTouchBegin. # - Made touch and camera presence use the same Presence Timeout and ramp. # - Added automatic return to each selected mode after presence finishes. # - Added touchscreen disable and display enforcement for Screen Lock. # - Retained actual Screen and Screensaver state topics as diagnostics. # # V1.7 - 2026-07-27 # - Re-enabled DPMS after XScreenSaver activation. # - Corrected Screen state reporting while the screensaver is running. # # V1.6 - 2026-07-27 # - Changed Screensaver ON to explicitly turn the display on through DPMS. # - Ensured the Screen state reports ON whenever the screensaver is enabled. # # V1.5 - 2026-07-27 # - Changed Motion Wake ON to immediately wake the display when motion is # currently detected or presence behaviour is already active. # - Removed the requirement to wait for a new motion event after enabling # Motion Wake. # # V1.4 - 2026-07-27 # - Added persistent MQTT control for motion-triggered display wake-up. # - Kept camera motion detection active while Motion Wake is disabled. # - Added retained Motion Wake state reporting. # # V1.3 - 2026-07-27 # - Replaced the fixed presence-timeout delay with a ten-step brightness ramp. # - Spread the ten ramp steps evenly across the configured timeout. # - Made the ramp follow the latest saved Current Brightness value. # - Added immediate ramp cancellation when new movement is detected. # # V1.2 - 2026-07-27 # - Added persistent Current Brightness and Presence Brightness values. # - Added local presence handling from the retained Motion MQTT state. # - Added a configurable local presence timeout. # - Added reporting of the actual physical backlight level. # # V1.1 - 2026-07-27 # - Added MQTT control and reporting for XScreenSaver. # - Forced XScreenSaver OFF whenever the control service starts. # - Changed Mosquitto commands to use protected client configuration files. # # V1.0 - 2026-07-26 # - Initial screen, brightness, volume, and mute controls. # set -u # Optional per-device shell configuration (never commit the real file). KIOSK_CONFIG_FILE="${KIOSK_CONFIG_FILE:-$HOME/.config/ha-kiosk/config}" if [[ -r "$KIOSK_CONFIG_FILE" ]]; then # shellcheck source=/dev/null source "$KIOSK_CONFIG_FILE" fi : "${KIOSK_ID:=kiosk}" : "${COMMAND_ROOT:=kiosk/commands}" : "${STATUS_ROOT:=kiosk/status}" : "${BROWSER_SERVICE:=ha-kiosk-browser.service}" : "${MQTT_REPUBLISH_INTERVAL_SECONDS:=30}" : "${MQTT_HEARTBEAT_TIMEOUT_SECONDS:=90}" : "${MOTION_SERVICE:=${KIOSK_ID}-motion.service}" : "${MOTION_EVENT_SCRIPT:=$HOME/${KIOSK_ID}-motion-event.sh}" : "${STATE_DIR:=${XDG_STATE_HOME:-$HOME/.local/state}/ha-kiosk-${KIOSK_ID}}" export DISPLAY="${KIOSK_DISPLAY:-:0}" export XAUTHORITY="${KIOSK_XAUTHORITY:-$HOME/.Xauthority}" #:########################################################################################:# # Device Settings # #:########################################################################################:# TOUCH_DEVICE_NAME="${TOUCH_DEVICE_NAME:-Touchscreen Device Name}" TOUCH_DISPLAY_OUTPUT="${TOUCH_DISPLAY_OUTPUT:-eDP-1}" SCREEN_LOCK_BRIGHTNESS="${SCREEN_LOCK_BRIGHTNESS:-0}" #:########################################################################################:# # MQTT Topics # #:########################################################################################:# DISPLAY_MODE_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/display_mode/set" DISPLAY_MODE_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/display_mode/state" # These state topics now report actual physical conditions only. SCREEN_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/screen/state" SCREENSAVER_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/screensaver/state" CURRENT_BRIGHTNESS_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/brightness/set" CURRENT_BRIGHTNESS_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/brightness/state" ACTUAL_BRIGHTNESS_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/brightness/actual/state" PRESENCE_BRIGHTNESS_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/brightness/presence/set" PRESENCE_BRIGHTNESS_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/brightness/presence/state" PRESENCE_ACTIVE_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/presence/state" PRESENCE_TIMEOUT_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/presence/timeout/set" PRESENCE_TIMEOUT_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/presence/timeout/state" PRESENCE_END_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/presence/end" MOTION_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/motion/state" MOTION_WAKE_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/motion_wake/set" MOTION_WAKE_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/motion_wake/state" MOTION_SERVICE="${MOTION_SERVICE}" MOTION_EVENT_SCRIPT="${MOTION_EVENT_SCRIPT}" VOLUME_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/audio/volume/set" VOLUME_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/audio/volume/state" MUTE_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/audio/mute/set" MUTE_STATE_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/audio/mute/state" CHROME_RESTART_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/chrome/restart" MQTT_HEARTBEAT_TOPIC="${STATUS_ROOT}/${KIOSK_ID}/control/heartbeat" MQTT_REPUBLISH_INTERVAL_SECONDS="${MQTT_REPUBLISH_INTERVAL_SECONDS:-30}" MQTT_HEARTBEAT_TIMEOUT_SECONDS="${MQTT_HEARTBEAT_TIMEOUT_SECONDS:-90}" # Previous command topics are cleared during initialisation and are no longer # subscribed to by this script. LEGACY_SCREEN_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/screen/set" LEGACY_SCREENSAVER_COMMAND_TOPIC="${COMMAND_ROOT}/${KIOSK_ID}/screensaver/set" #:########################################################################################:# # Local State # #:########################################################################################:# STATE_DIR="${STATE_DIR}" DISPLAY_MODE_FILE="$STATE_DIR/display_mode" CURRENT_BRIGHTNESS_FILE="$STATE_DIR/current_brightness" PRESENCE_BRIGHTNESS_FILE="$STATE_DIR/presence_brightness" PRESENCE_ACTIVE_FILE="$STATE_DIR/presence_active" PRESENCE_TIMEOUT_FILE="$STATE_DIR/presence_timeout_seconds" MOTION_WAKE_FILE="$STATE_DIR/motion_wake" MQTT_EVENT_FIFO="$STATE_DIR/mqtt-events.fifo" TOUCH_EVENT_FIFO="$STATE_DIR/touch-events.fifo" mkdir -p "$STATE_DIR" chmod 700 "$STATE_DIR" #:########################################################################################:# # MQTT Publishing # #:########################################################################################:# mqtt_publish() { /usr/bin/mosquitto_pub "$@" } publish_retained() { local topic="$1" local payload="$2" mqtt_publish \ -r \ -t "$topic" \ -m "$payload" } #:########################################################################################:# # Value Helpers # #:########################################################################################:# clamp_integer() { local value="$1" local minimum="$2" local maximum="$3" if (( value < minimum )); then value="$minimum" elif (( value > maximum )); then value="$maximum" fi printf '%s\n' "$value" } read_integer_file() { local file="$1" local fallback="$2" local minimum="$3" local maximum="$4" local value="" if [[ -r "$file" ]]; then read -r value < "$file" || true fi if [[ ! "$value" =~ ^[0-9]+$ ]]; then value="$fallback" fi clamp_integer "$value" "$minimum" "$maximum" } write_state_file() { local file="$1" local value="$2" local temporary_file="${file}.tmp" printf '%s\n' "$value" > "$temporary_file" chmod 600 "$temporary_file" mv -f "$temporary_file" "$file" } get_current_brightness() { read_integer_file "$CURRENT_BRIGHTNESS_FILE" 100 1 100 } get_presence_brightness() { read_integer_file "$PRESENCE_BRIGHTNESS_FILE" 80 1 100 } get_presence_timeout() { read_integer_file "$PRESENCE_TIMEOUT_FILE" 300 5 86400 } get_motion_wake() { local value="ON" if [[ -r "$MOTION_WAKE_FILE" ]]; then read -r value < "$MOTION_WAKE_FILE" || true fi case "${value^^}" in OFF) printf 'OFF\n' ;; *) printf 'ON\n' ;; esac } get_presence_active() { local value="OFF" if [[ -r "$PRESENCE_ACTIVE_FILE" ]]; then read -r value < "$PRESENCE_ACTIVE_FILE" || true fi case "${value^^}" in ON) printf 'ON\n' ;; *) printf 'OFF\n' ;; esac } get_display_mode() { local value="Screen On" if [[ -r "$DISPLAY_MODE_FILE" ]]; then read -r value < "$DISPLAY_MODE_FILE" || true fi case "${value^^}" in "SCREEN LOCK") printf 'Screen Lock\n' ;; "SCREEN OFF") printf 'Screen Off\n' ;; "SCREENSAVER") printf 'Screensaver\n' ;; *) printf 'Screen On\n' ;; esac } #:########################################################################################:# # Camera Service Functions # #:########################################################################################:# camera_should_run() { if [[ "$(get_motion_wake)" == "ON" ]] && [[ "$(get_display_mode)" != "Screen Lock" ]]; then return 0 fi return 1 } sync_camera_service() { if camera_should_run; then if ! /usr/bin/systemctl --user \ is-active --quiet "$MOTION_SERVICE"; then /usr/bin/systemctl --user \ start "$MOTION_SERVICE" \ >/dev/null 2>&1 || true fi else if /usr/bin/systemctl --user \ is-active --quiet "$MOTION_SERVICE"; then /usr/bin/systemctl --user \ stop "$MOTION_SERVICE" \ >/dev/null 2>&1 || true fi # Ensure the retained Motion state returns to OFF even when Motion is # deliberately stopped before it publishes an event-end message. if [[ -x "$MOTION_EVENT_SCRIPT" ]]; then "$MOTION_EVENT_SCRIPT" end >/dev/null 2>&1 || true fi fi } set_presence_active() { local value="${1^^}" case "$value" in ON|OFF) write_state_file "$PRESENCE_ACTIVE_FILE" "$value" publish_retained "$PRESENCE_ACTIVE_STATE_TOPIC" "$value" ;; esac } set_display_mode_state() { local mode="$1" write_state_file "$DISPLAY_MODE_FILE" "$mode" publish_retained "$DISPLAY_MODE_STATE_TOPIC" "$mode" } #:########################################################################################:# # Display and Audio Helpers # #:########################################################################################:# set_physical_brightness() { local brightness="$1" /usr/bin/brightnessctl \ --device=intel_backlight \ set "${brightness}%" \ >/dev/null } get_physical_brightness() { /usr/bin/brightnessctl \ --device=intel_backlight \ --machine-readable | /usr/bin/cut -d',' -f4 | /usr/bin/tr -d '%' } get_screen_state() { /usr/bin/xset q | /usr/bin/awk '/Monitor is/ { print toupper($3) }' } touchscreen_is_enabled() { /usr/bin/xinput \ list-props "$TOUCH_DEVICE_NAME" | /usr/bin/awk '/Device Enabled/ { print $NF; exit }' } touchscreen_enable() { /usr/bin/xinput \ enable "$TOUCH_DEVICE_NAME" \ >/dev/null 2>&1 || true /usr/bin/xinput \ map-to-output "$TOUCH_DEVICE_NAME" "$TOUCH_DISPLAY_OUTPUT" \ >/dev/null 2>&1 || true } touchscreen_disable() { /usr/bin/xinput \ disable "$TOUCH_DEVICE_NAME" \ >/dev/null 2>&1 || true } #:########################################################################################:# # Chrome Kiosk Functions # #:########################################################################################:# restart_chrome_kiosk() { /usr/bin/systemctl --user restart "$BROWSER_SERVICE" } #:########################################################################################:# # Screensaver Functions # #:########################################################################################:# screensaver_is_running() { /usr/bin/pgrep -x xscreensaver >/dev/null 2>&1 } screensaver_start() { # The screensaver requires the physical display to be on. /usr/bin/xset +dpms /usr/bin/xset dpms force on if ! screensaver_is_running; then /usr/bin/xscreensaver \ -no-splash \ >/dev/null 2>&1 & for _ in {1..20}; do if screensaver_is_running; then break fi /usr/bin/sleep 0.1 done fi /usr/bin/xscreensaver-command \ -activate \ >/dev/null 2>&1 || true # XScreenSaver disables DPMS while activating. Re-enable it afterwards so # physical screen state reporting and manual DPMS control continue to work. /usr/bin/xset +dpms /usr/bin/xset dpms force on } screensaver_stop() { /usr/bin/xscreensaver-command \ -exit \ >/dev/null 2>&1 || true /usr/bin/pkill \ -x glmatrix \ >/dev/null 2>&1 || true } #:########################################################################################:# # Display Mode Functions # #:########################################################################################:# apply_display_mode_base_state() { local mode="" local current_brightness="" mode="$(get_display_mode)" current_brightness="$(get_current_brightness)" case "$mode" in "Screen Lock") touchscreen_disable screensaver_stop /usr/bin/xset +dpms /usr/bin/xset dpms force off set_physical_brightness "$SCREEN_LOCK_BRIGHTNESS" ;; "Screen Off") touchscreen_enable screensaver_stop set_physical_brightness "$SCREEN_LOCK_BRIGHTNESS" /usr/bin/xset +dpms /usr/bin/xset dpms force off ;; "Screensaver") touchscreen_enable set_physical_brightness "$current_brightness" screensaver_start ;; "Screen On") touchscreen_enable screensaver_stop set_physical_brightness "$current_brightness" /usr/bin/xset +dpms /usr/bin/xset dpms force on ;; esac } enforce_display_mode() { local mode="" local screen_state="" local touch_enabled="" mode="$(get_display_mode)" screen_state="$(get_screen_state)" touch_enabled="$(touchscreen_is_enabled)" case "$mode" in "Screen Lock") if [[ "$touch_enabled" != "0" ]]; then touchscreen_disable fi if screensaver_is_running; then screensaver_stop fi if [[ "$screen_state" != "OFF" ]] || [[ "$(get_physical_brightness)" -gt "$SCREEN_LOCK_BRIGHTNESS" ]]; then printf '%s Screen Lock corrected unexpected DPMS state: %s\n' \ "$(/usr/bin/date --iso-8601=seconds)" \ "${screen_state:-UNKNOWN}" \ >&2 /usr/bin/xset +dpms /usr/bin/xset dpms force off set_physical_brightness "$SCREEN_LOCK_BRIGHTNESS" fi ;; "Screen Off") if [[ "$touch_enabled" != "1" ]]; then touchscreen_enable fi if [[ "$(get_presence_active)" != "ON" ]]; then if screensaver_is_running; then screensaver_stop fi #if [[ "$screen_state" != "OFF" ]]; then /usr/bin/xset +dpms /usr/bin/xset dpms force off #fi fi ;; "Screensaver") if [[ "$touch_enabled" != "1" ]]; then touchscreen_enable fi if [[ "$(get_presence_active)" != "ON" ]]; then if [[ "$screen_state" != "ON" ]] || ! screensaver_is_running; then screensaver_start fi fi ;; "Screen On") if [[ "$touch_enabled" != "1" ]]; then touchscreen_enable fi if [[ "$(get_presence_active)" != "ON" ]]; then if screensaver_is_running; then screensaver_stop fi if [[ "$screen_state" != "ON" ]]; then /usr/bin/xset +dpms /usr/bin/xset dpms force on fi fi ;; esac } set_display_mode() { local mode="$1" cancel_presence_ramp set_presence_active "OFF" set_display_mode_state "$mode" apply_display_mode_base_state sync_camera_service } #:########################################################################################:# # Presence Functions # #:########################################################################################:# presence_ramp_pid="" cancel_presence_ramp() { if [[ -n "$presence_ramp_pid" ]] && /usr/bin/kill -0 "$presence_ramp_pid" >/dev/null 2>&1; then /usr/bin/kill "$presence_ramp_pid" >/dev/null 2>&1 || true wait "$presence_ramp_pid" 2>/dev/null || true fi presence_ramp_pid="" } activate_presence() { local mode="" local presence_brightness="" mode="$(get_display_mode)" if [[ "$mode" == "Screen Lock" ]]; then return fi cancel_presence_ramp presence_brightness="$(get_presence_brightness)" touchscreen_enable screensaver_stop /usr/bin/xset +dpms /usr/bin/xset dpms force on set_physical_brightness "$presence_brightness" set_presence_active "ON" } run_presence_ramp() { local timeout_seconds="${1:-}" local interval_seconds="" local remaining_steps="" local actual_brightness="" local target_brightness="" local brightness_difference="" local brightness_change="" local next_brightness="" if [[ -z "$timeout_seconds" ]]; then timeout_seconds="$(get_presence_timeout)" fi interval_seconds="$( /usr/bin/awk \ -v timeout="$timeout_seconds" \ 'BEGIN { printf "%.3f\n", timeout / 10 }' )" for ((remaining_steps = 10; remaining_steps >= 1; remaining_steps--)); do /usr/bin/sleep "$interval_seconds" # Stop without changing the selected display mode when cancelled. if [[ "$(get_presence_active)" != "ON" ]]; then return fi actual_brightness="$(get_physical_brightness)" target_brightness="$(get_current_brightness)" if [[ ! "$actual_brightness" =~ ^[0-9]+$ ]] || [[ ! "$target_brightness" =~ ^[0-9]+$ ]]; then continue fi brightness_difference=$((target_brightness - actual_brightness)) # The final step always applies the exact latest Current Brightness. if (( remaining_steps == 1 )); then next_brightness="$target_brightness" elif (( brightness_difference >= 0 )); then brightness_change=$(( (brightness_difference + remaining_steps / 2) / remaining_steps )) next_brightness=$((actual_brightness + brightness_change)) else brightness_change=$(( ((-brightness_difference) + remaining_steps / 2) / remaining_steps )) next_brightness=$((actual_brightness - brightness_change)) fi next_brightness="$( clamp_integer "$next_brightness" 1 100 )" set_physical_brightness "$next_brightness" done # Re-read the target so the final level includes any Current Brightness # changes received while the ramp was running. target_brightness="$(get_current_brightness)" set_physical_brightness "$target_brightness" set_presence_active "OFF" apply_display_mode_base_state } schedule_presence_ramp() { local timeout_seconds="${1:-}" cancel_presence_ramp if [[ "$(get_presence_active)" != "ON" ]]; then return fi run_presence_ramp "$timeout_seconds" & presence_ramp_pid="$!" } handle_touch_activity() { if [[ "$(get_display_mode)" == "Screen Lock" ]]; then return fi activate_presence # Qualifying camera motion holds presence active. Otherwise, a touch starts # or restarts the shared Presence Timeout immediately. if [[ "$current_motion_state" == "ON" ]] && [[ "$(get_motion_wake)" == "ON" ]]; then return fi schedule_presence_ramp } #:########################################################################################:# # Initialisation # #:########################################################################################:# current_display_mode="$(get_display_mode)" current_brightness="$(get_current_brightness)" presence_brightness="$(get_presence_brightness)" presence_timeout="$(get_presence_timeout)" motion_wake="$(get_motion_wake)" write_state_file "$DISPLAY_MODE_FILE" "$current_display_mode" write_state_file "$CURRENT_BRIGHTNESS_FILE" "$current_brightness" write_state_file "$PRESENCE_BRIGHTNESS_FILE" "$presence_brightness" write_state_file "$PRESENCE_TIMEOUT_FILE" "$presence_timeout" write_state_file "$PRESENCE_ACTIVE_FILE" "OFF" write_state_file "$MOTION_WAKE_FILE" "$motion_wake" # Enable manual DPMS control without configuring an automatic display-off timeout. /usr/bin/xset +dpms # A service restart ends temporary presence and restores the selected base mode. screensaver_stop set_presence_active "OFF" apply_display_mode_base_state sync_camera_service # Remove retained commands from the two controls replaced by Display Mode. for legacy_topic in \ "$LEGACY_SCREEN_COMMAND_TOPIC" \ "$LEGACY_SCREENSAVER_COMMAND_TOPIC"; do mqtt_publish \ -r \ -n \ -t "$legacy_topic" \ >/dev/null 2>&1 || true done publish_retained "$DISPLAY_MODE_STATE_TOPIC" "$current_display_mode" publish_retained "$CURRENT_BRIGHTNESS_STATE_TOPIC" "$current_brightness" publish_retained "$PRESENCE_BRIGHTNESS_STATE_TOPIC" "$presence_brightness" publish_retained "$PRESENCE_TIMEOUT_STATE_TOPIC" "$presence_timeout" publish_retained "$PRESENCE_ACTIVE_STATE_TOPIC" "OFF" publish_retained "$MOTION_WAKE_STATE_TOPIC" "$motion_wake" #:########################################################################################:# # State Reporting # #:########################################################################################:# ( previous_display_mode="" previous_screen_state="" previous_actual_brightness="" previous_current_brightness="" previous_presence_brightness="" previous_presence_active="" previous_presence_timeout="" previous_motion_wake="" previous_volume="" previous_mute_state="" previous_screensaver_state="" last_full_state_publish=0 while true; do current_display_mode="$(get_display_mode)" current_screen_state="$(get_screen_state)" actual_brightness="$(get_physical_brightness)" saved_current_brightness="$(get_current_brightness)" saved_presence_brightness="$(get_presence_brightness)" current_presence_active="$(get_presence_active)" saved_presence_timeout="$(get_presence_timeout)" current_motion_wake="$(get_motion_wake)" audio_state="$( /usr/bin/wpctl get-volume @DEFAULT_AUDIO_SINK@ )" current_volume="$( /usr/bin/awk \ '{ printf "%.0f\n", $2 * 100 }' \ <<< "$audio_state" )" if /usr/bin/grep -q '\[MUTED\]' <<< "$audio_state"; then current_mute_state="ON" else current_mute_state="OFF" fi if screensaver_is_running; then current_screensaver_state="ON" else current_screensaver_state="OFF" fi state_report_epoch="$(/usr/bin/date +%s)" if (( state_report_epoch - last_full_state_publish >= MQTT_REPUBLISH_INTERVAL_SECONDS )); then # Reassert retained state after a broker restart, even when unchanged. mqtt_publish -t "$MQTT_HEARTBEAT_TOPIC" -m "$state_report_epoch" publish_retained "$DISPLAY_MODE_STATE_TOPIC" "$current_display_mode" if [[ "$current_screen_state" == "ON" || "$current_screen_state" == "OFF" ]]; then publish_retained "$SCREEN_STATE_TOPIC" "$current_screen_state" fi if [[ "$actual_brightness" =~ ^[0-9]+$ ]]; then publish_retained "$ACTUAL_BRIGHTNESS_STATE_TOPIC" "$actual_brightness" fi publish_retained "$CURRENT_BRIGHTNESS_STATE_TOPIC" "$saved_current_brightness" publish_retained "$PRESENCE_BRIGHTNESS_STATE_TOPIC" "$saved_presence_brightness" publish_retained "$PRESENCE_ACTIVE_STATE_TOPIC" "$current_presence_active" publish_retained "$PRESENCE_TIMEOUT_STATE_TOPIC" "$saved_presence_timeout" publish_retained "$MOTION_WAKE_STATE_TOPIC" "$current_motion_wake" if [[ "$current_volume" =~ ^[0-9]+$ ]]; then publish_retained "$VOLUME_STATE_TOPIC" "$current_volume" fi publish_retained "$MUTE_STATE_TOPIC" "$current_mute_state" publish_retained "$SCREENSAVER_STATE_TOPIC" "$current_screensaver_state" last_full_state_publish="$state_report_epoch" fi if [[ "$current_display_mode" != "$previous_display_mode" ]]; then publish_retained "$DISPLAY_MODE_STATE_TOPIC" "$current_display_mode" previous_display_mode="$current_display_mode" fi if [[ "$current_screen_state" == "ON" || "$current_screen_state" == "OFF" ]]; then if [[ "$current_screen_state" != "$previous_screen_state" ]]; then publish_retained "$SCREEN_STATE_TOPIC" "$current_screen_state" previous_screen_state="$current_screen_state" fi fi if [[ "$actual_brightness" =~ ^[0-9]+$ ]] && [[ "$actual_brightness" != "$previous_actual_brightness" ]]; then publish_retained "$ACTUAL_BRIGHTNESS_STATE_TOPIC" "$actual_brightness" previous_actual_brightness="$actual_brightness" fi if [[ "$saved_current_brightness" != "$previous_current_brightness" ]]; then publish_retained "$CURRENT_BRIGHTNESS_STATE_TOPIC" "$saved_current_brightness" previous_current_brightness="$saved_current_brightness" fi if [[ "$saved_presence_brightness" != "$previous_presence_brightness" ]]; then publish_retained "$PRESENCE_BRIGHTNESS_STATE_TOPIC" "$saved_presence_brightness" previous_presence_brightness="$saved_presence_brightness" fi if [[ "$current_presence_active" != "$previous_presence_active" ]]; then publish_retained "$PRESENCE_ACTIVE_STATE_TOPIC" "$current_presence_active" previous_presence_active="$current_presence_active" fi if [[ "$saved_presence_timeout" != "$previous_presence_timeout" ]]; then publish_retained "$PRESENCE_TIMEOUT_STATE_TOPIC" "$saved_presence_timeout" previous_presence_timeout="$saved_presence_timeout" fi if [[ "$current_motion_wake" != "$previous_motion_wake" ]]; then publish_retained "$MOTION_WAKE_STATE_TOPIC" "$current_motion_wake" previous_motion_wake="$current_motion_wake" fi if [[ "$current_volume" =~ ^[0-9]+$ ]] && [[ "$current_volume" != "$previous_volume" ]]; then publish_retained "$VOLUME_STATE_TOPIC" "$current_volume" previous_volume="$current_volume" fi if [[ "$current_mute_state" != "$previous_mute_state" ]]; then publish_retained "$MUTE_STATE_TOPIC" "$current_mute_state" previous_mute_state="$current_mute_state" fi if [[ "$current_screensaver_state" != "$previous_screensaver_state" ]]; then publish_retained "$SCREENSAVER_STATE_TOPIC" "$current_screensaver_state" previous_screensaver_state="$current_screensaver_state" fi /usr/bin/sleep 2 done ) & state_reporter_pid="$!" #:########################################################################################:# # Event Listeners # #:########################################################################################:# rm -f "$MQTT_EVENT_FIFO" "$TOUCH_EVENT_FIFO" mkfifo "$MQTT_EVENT_FIFO" "$TOUCH_EVENT_FIFO" chmod 600 "$MQTT_EVENT_FIFO" "$TOUCH_EVENT_FIFO" # Open both FIFOs read/write in the parent so listener startup does not block and # a listener failure cannot silently end the main event loop. exec 3<>"$MQTT_EVENT_FIFO" exec 4<>"$TOUCH_EVENT_FIFO" /usr/bin/stdbuf -oL /usr/bin/mosquitto_sub \ -t "$DISPLAY_MODE_COMMAND_TOPIC" \ -t "$CURRENT_BRIGHTNESS_COMMAND_TOPIC" \ -t "$PRESENCE_BRIGHTNESS_COMMAND_TOPIC" \ -t "$PRESENCE_TIMEOUT_COMMAND_TOPIC" \ -t "$PRESENCE_END_COMMAND_TOPIC" \ -t "$MOTION_STATE_TOPIC" \ -t "$MOTION_WAKE_COMMAND_TOPIC" \ -t "$VOLUME_COMMAND_TOPIC" \ -t "$MUTE_COMMAND_TOPIC" \ -t "$CHROME_RESTART_COMMAND_TOPIC" \ -t "$MQTT_HEARTBEAT_TOPIC" \ -F "%t|%p" \ > "$MQTT_EVENT_FIFO" & mqtt_listener_pid="$!" ( /usr/bin/stdbuf \ -oL \ /usr/bin/xinput test-xi2 --root | /usr/bin/awk ' /EVENT type 22 \(RawTouchBegin\)/ { print "TOUCH" fflush() } ' ) > "$TOUCH_EVENT_FIFO" & touch_listener_pid="$!" cleanup() { cancel_presence_ramp for process_id in \ "$state_reporter_pid" \ "$mqtt_listener_pid" \ "$touch_listener_pid"; do if /usr/bin/kill -0 "$process_id" >/dev/null 2>&1; then /usr/bin/kill "$process_id" >/dev/null 2>&1 || true fi done exec 3>&- 3<&- exec 4>&- 4<&- rm -f "$MQTT_EVENT_FIFO" "$TOUCH_EVENT_FIFO" } trap cleanup EXIT INT TERM #:########################################################################################:# # MQTT Command Handler # #:########################################################################################:# current_motion_state="OFF" handle_mqtt_message() { local topic="$1" local payload="$2" case "$topic" in "$MQTT_HEARTBEAT_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]]; then last_mqtt_heartbeat_epoch="$payload" fi ;; "$DISPLAY_MODE_COMMAND_TOPIC") case "${payload^^}" in "SCREEN LOCK") set_display_mode "Screen Lock" ;; "SCREEN OFF") set_display_mode "Screen Off" ;; "SCREENSAVER") set_display_mode "Screensaver" ;; "SCREEN ON") set_display_mode "Screen On" ;; esac ;; "$CURRENT_BRIGHTNESS_COMMAND_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]]; then payload="$(clamp_integer "$payload" 1 100)" write_state_file "$CURRENT_BRIGHTNESS_FILE" "$payload" publish_retained "$CURRENT_BRIGHTNESS_STATE_TOPIC" "$payload" if [[ "$(get_presence_active)" != "ON" ]]; then case "$(get_display_mode)" in "Screensaver"|"Screen On") set_physical_brightness "$payload" ;; esac fi fi ;; "$PRESENCE_BRIGHTNESS_COMMAND_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]]; then payload="$(clamp_integer "$payload" 1 100)" write_state_file "$PRESENCE_BRIGHTNESS_FILE" "$payload" publish_retained "$PRESENCE_BRIGHTNESS_STATE_TOPIC" "$payload" if [[ "$(get_presence_active)" == "ON" ]]; then set_physical_brightness "$payload" fi fi ;; "$PRESENCE_TIMEOUT_COMMAND_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]]; then payload="$(clamp_integer "$payload" 5 86400)" write_state_file "$PRESENCE_TIMEOUT_FILE" "$payload" publish_retained "$PRESENCE_TIMEOUT_STATE_TOPIC" "$payload" if [[ "$(get_presence_active)" == "ON" ]]; then if [[ "$current_motion_state" != "ON" ]] || [[ "$(get_motion_wake)" != "ON" ]]; then schedule_presence_ramp fi fi fi ;; "$PRESENCE_END_COMMAND_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]] && (( payload >= 1 && payload <= 3600 )); then schedule_presence_ramp "$payload" fi ;; "$MOTION_STATE_TOPIC") case "${payload^^}" in ON) current_motion_state="ON" if [[ "$(get_motion_wake)" == "ON" ]] && [[ "$(get_display_mode)" != "Screen Lock" ]]; then activate_presence fi ;; OFF) current_motion_state="OFF" if [[ "$(get_motion_wake)" == "ON" ]] && [[ "$(get_presence_active)" == "ON" ]]; then schedule_presence_ramp fi ;; esac ;; "$MOTION_WAKE_COMMAND_TOPIC") case "${payload^^}" in ON) write_state_file "$MOTION_WAKE_FILE" "ON" publish_retained "$MOTION_WAKE_STATE_TOPIC" "ON" sync_camera_service if [[ "$current_motion_state" == "ON" ]] && [[ "$(get_display_mode)" != "Screen Lock" ]]; then activate_presence fi ;; OFF) write_state_file "$MOTION_WAKE_FILE" "OFF" publish_retained "$MOTION_WAKE_STATE_TOPIC" "OFF" sync_camera_service # Disabling Motion Wake stops current motion from indefinitely holding # presence active. Touch-triggered presence still uses the same timeout. if [[ "$current_motion_state" == "ON" ]] && [[ "$(get_presence_active)" == "ON" ]]; then schedule_presence_ramp fi ;; esac ;; "$VOLUME_COMMAND_TOPIC") if [[ "$payload" =~ ^[0-9]+$ ]]; then payload="$(clamp_integer "$payload" 0 100)" /usr/bin/wpctl \ set-volume \ @DEFAULT_AUDIO_SINK@ \ "${payload}%" fi ;; "$MUTE_COMMAND_TOPIC") case "${payload^^}" in ON) /usr/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ 1 ;; OFF) /usr/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 ;; esac ;; "$CHROME_RESTART_COMMAND_TOPIC") case "${payload^^}" in PRESS|RESTART) restart_chrome_kiosk ;; esac ;; esac } #:########################################################################################:# # Main Event Loop # #:########################################################################################:# last_mqtt_heartbeat_epoch="$(/usr/bin/date +%s)" while true; do if ! /usr/bin/kill -0 "$mqtt_listener_pid" >/dev/null 2>&1; then exit 1 fi if ! /usr/bin/kill -0 "$touch_listener_pid" >/dev/null 2>&1; then exit 1 fi current_epoch="$(/usr/bin/date +%s)" if (( current_epoch - last_mqtt_heartbeat_epoch > MQTT_HEARTBEAT_TIMEOUT_SECONDS )); then exit 1 fi if IFS='|' read -r -t 0.20 -u 3 topic payload; then handle_mqtt_message "$topic" "$payload" # Drain any additional MQTT messages already waiting in the FIFO. while IFS='|' read -r -t 0.01 -u 3 topic payload; do handle_mqtt_message "$topic" "$payload" done fi while IFS= read -r -t 0.01 -u 4 touch_event; do if [[ "$touch_event" == "TOUCH" ]]; then handle_touch_activity fi done # Check the requested display mode after every event-loop cycle. # The MQTT read timeout limits this to approximately five checks per second. enforce_display_mode done