Files

1257 lines
38 KiB
Bash
Executable File

#!/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