From e8221faeb148c02c1fe38a8673478a800abadba7 Mon Sep 17 00:00:00 2001 From: niten Date: Sun, 15 Feb 2026 23:42:53 -0800 Subject: [PATCH] Convert to Babashka script with modern idle detection Replace heavyweight JVM Clojure with Babashka for instant startup and simplicity. The script now uses mosquitto_pub CLI tool instead of the Java MQTT client library. Key improvements: - Use systemd-logind for idle detection (works with X11 and Wayland) - Fallback to xprintidle for legacy X11-only systems - Replace Paho MQTT client with mosquitto_pub CLI - Eliminate all external dependencies (use Babashka built-ins) - Add shebang for direct execution - Fix compatibility with NixOS 25.11 and modern Wayland systems This resolves the 'screen saver extension not supported' error by using loginctl to query systemd-logind instead of relying on X11 extensions. --- deps.edn | 28 +++---- src/wallfly/core.clj | 179 ++++++++++++++++++++++++++++++------------- 2 files changed, 141 insertions(+), 66 deletions(-) mode change 100644 => 100755 src/wallfly/core.clj diff --git a/deps.edn b/deps.edn index fe944e6..5c6bd1e 100644 --- a/deps.edn +++ b/deps.edn @@ -1,20 +1,23 @@ { :paths ["src"] :deps { - org.clojure/clojure { :mvn/version "1.12.3" } - org.clojure/core.async { :mvn/version "1.8.741" } - org.clojure/tools.cli { :mvn/version "1.3.250" } - - org.fudo/fudo-clojure { - :git/url "https://fudo.dev/public/fudo-clojure.git" - :sha "65bf45b72f783fe5c962c9d413685fbc0cd69779" - } - - org.eclipse.paho/org.eclipse.paho.client.mqttv3 { :mvn/version "1.2.5" } - camel-snake-kebab/camel-snake-kebab { :mvn/version "0.4.2" } - com.taoensso/timbre { :mvn/version "6.6.1" } + ;; All dependencies are now built into Babashka: + ;; - babashka.process (for shell commands) + ;; - clojure.core.async (for concurrency) + ;; - clojure.tools.cli (for CLI parsing) + ;; - cheshire.core (for JSON) + ;; + ;; This script no longer requires any external dependencies! } :aliases { + ;; For running with regular Clojure (if needed for development): + :jvm { + :extra-deps { + org.clojure/clojure { :mvn/version "1.12.3" } + org.clojure/core.async { :mvn/version "1.8.741" } + org.clojure/tools.cli { :mvn/version "1.3.250" } + } + } :test { :extra-paths ["test"] :extra-deps { @@ -22,6 +25,5 @@ } :main-opts ["-e" "(require '[eftest.runner :refer [find-tests run-tests]]) (run-tests (find-tests \"test\"))"] } - :build { :default-ns build } } } diff --git a/src/wallfly/core.clj b/src/wallfly/core.clj old mode 100644 new mode 100755 index e59cb56..395096d --- a/src/wallfly/core.clj +++ b/src/wallfly/core.clj @@ -1,63 +1,130 @@ +#!/usr/bin/env bb + (ns wallfly.core - (:require [clojure.java.shell :as shell] + (:require [babashka.process :as process] [clojure.core.async :refer [chan >!! SCREAMING_SNAKE_CASE]]) - (:import [org.eclipse.paho.client.mqttv3 - MqttClient - MqttConnectOptions - MqttMessage] - org.eclipse.paho.client.mqttv3.persist.MemoryPersistence) - (:gen-class)) + [clojure.pprint :refer [pprint]])) (defn- exit! [status msg] (println msg) (System/exit status)) -(defn- create-mqtt-client [broker-uri client-id mqtt-username passwd] - (let [opts (doto (MqttConnectOptions.) - (.setCleanSession true) - (.setAutomaticReconnect true) - (.setPassword (char-array passwd)) - (.setUserName mqtt-username))] - (try - (doto (MqttClient. broker-uri client-id (MemoryPersistence.)) - (.connect opts)) - (catch Exception e - (exit! 1 (.getMessage e)))))) +(defn- parse-broker-uri [uri] + "Parse MQTT broker URI like tcp://host:port or ssl://host:port" + (let [uri-pattern #"^(tcp|ssl)://([^:]+)(?::(\d+))?$" + [_ protocol host port] (re-matches uri-pattern uri)] + (when-not host + (exit! 1 (str "Invalid broker URI format: " uri))) + {:host host + :port (or port (if (= protocol "ssl") "8883" "1883")) + :use-tls (= protocol "ssl")})) + +(defn- create-mqtt-config [broker-uri mqtt-username passwd] + "Create MQTT configuration for mosquitto_pub" + (merge (parse-broker-uri broker-uri) + {:username mqtt-username + :password passwd})) (defn- shell-exec [& args] - (let [{:keys [exit out err]} (apply shell/sh args)] - (if (= exit 0) - (success (trim-newline out)) - (failure err { :error err :status-code exit })))) + "Execute shell command and return {:success true :out output} or {:success false :error error}" + (try + (let [result (process/shell {:out :string :err :string} (str/join " " args)) + out (str/trim (:out result))] + {:success true :out out}) + (catch Exception e + {:success false :error (ex-message e)}))) + +(defn- get-current-session-id [] + "Get the current user's session ID from loginctl" + (let [username (System/getenv "USER") + result (shell-exec "loginctl" "list-sessions" "--no-legend")] + (when (:success result) + (->> (str/split-lines (:out result)) + (filter #(str/includes? % username)) + first + (re-find #"^\s*(\S+)") + second)))) + +(defn- get-idle-time-from-loginctl [] + "Get idle time in seconds using systemd-logind (works with X11 and Wayland)" + (if-let [session-id (get-current-session-id)] + (let [idle-hint-result (shell-exec "loginctl" "show-session" session-id "-p" "IdleHint" "--value") + idle-since-result (shell-exec "loginctl" "show-session" session-id "-p" "IdleSinceHintMonotonic" "--value")] + (if (and (:success idle-hint-result) (:success idle-since-result)) + (if (= "yes" (:out idle-hint-result)) + ;; Session is idle - calculate how long + (let [idle-since-usec (Long/parseLong (:out idle-since-result)) + uptime-result (shell-exec "awk" "{print $1*1000000}" "/proc/uptime") + now-usec (when (:success uptime-result) (long (Double/parseDouble (:out uptime-result)))) + idle-usec (when now-usec (- now-usec idle-since-usec)) + idle-sec (when idle-usec (max 0 (quot idle-usec 1000000)))] + (if idle-sec + {:success true :out idle-sec} + {:success false :error "Failed to calculate idle time"})) + ;; Session is not idle + {:success true :out 0}) + {:success false :error (str "Failed to get session idle info: " (:error idle-since-result))})) + {:success false :error "Could not determine current session"})) + +(defn- get-idle-time-from-xprintidle [] + "Get idle time in seconds using xprintidle (X11 only, legacy fallback)" + (let [result (shell-exec "xprintidle")] + (if (:success result) + {:success true :out (quot (Integer/parseInt (:out result)) 1000)} + result))) (defn- get-idle-time [] - (map-success (shell-exec "xprintidle") - (fn [idle-str] (quot (Integer/parseInt idle-str) 1000)))) + "Get idle time in seconds. + + Tries systemd-logind first (works with both X11 and Wayland), + falls back to xprintidle (X11 only) if loginctl fails. + + This ensures compatibility with modern Linux systems that use Wayland, + while maintaining backwards compatibility with X11-only systems." + (let [result (get-idle-time-from-loginctl)] + (if (:success result) + result + (get-idle-time-from-xprintidle)))) -(defn- get-hostname [] (unwrap (shell-exec "hostname"))) +(defn- get-hostname [] + (let [result (shell-exec "hostname")] + (if (:success result) + (:out result) + (exit! 1 (str "Failed to get hostname: " (:error result)))))) -(defn- get-fqdn [] (unwrap (shell-exec "hostname" "-f"))) +(defn- get-fqdn [] + (let [result (shell-exec "hostname" "-f")] + (if (:success result) + (:out result) + (exit! 1 (str "Failed to get FQDN: " (:error result)))))) (defn- get-username [] (System/getenv "USER")) -(defn- create-message [msg retained] - (doto (MqttMessage. (.getBytes msg)) - (.setQos 1) - (.setRetained retained))) +(defn- send-message [mqtt-config topic msg & {:keys [retained] :or {retained false}}] + "Send MQTT message using mosquitto_pub" + (let [{:keys [host port username password use-tls]} mqtt-config + base-args ["mosquitto_pub" + "-h" host + "-p" (str port) + "-u" username + "-P" password + "-t" topic + "-m" msg + "-q" "1"] + args (if retained + (conj base-args "-r") + base-args)] + (try + (let [result @(process/process args {:out :string :err :string})] + (when (not= 0 (:exit result)) + (exit! 1 (str "mosquitto_pub failed: " (:err result))))) + (catch Exception e + (exit! 1 (str "Failed to send MQTT message: " (ex-message e))))))) -(defn- send-message [client topic msg & {:keys [retained] :or {retained false}}] - (try - (.publish client topic (create-message msg retained)) - (catch Exception e - (exit! 1 (.getMessage e))))) - -(defn- create-reporter [client time-to-idle location user host host-device] +(defn- create-reporter [mqtt-config time-to-idle location user host host-device] (let [base-topic (format "homeassistant/binary_sensor/wallfly_%s_%s" user host) presence-topic (format "%s/state" base-topic)] @@ -75,13 +142,13 @@ :manufacturer "Fudo" :suggested_area location}}] (println (format "sending to %s: %s" cfg-topic (with-out-str (pprint payload)))) - (send-message client cfg-topic (json/write-str payload) :retained true) + (send-message mqtt-config cfg-topic (json/generate-string payload) :retained true) ;; Send one message that will persist if the host dies - (send-message client presence-topic "OFF" :retained true)) + (send-message mqtt-config presence-topic "OFF" :retained true)) (fn [idle-time] ;; (emit-idle idle-time) (when (< idle-time time-to-idle) - (send-message client presence-topic "ON"))))) + (send-message mqtt-config presence-topic "ON"))))) (defn- execute! [delay-seconds report] (let [stop-chan (chan) @@ -89,9 +156,9 @@ (go-loop [idle-measure (get-idle-time)] (if (nil? idle-measure) nil - (do (if (success? idle-measure) - (report (unwrap idle-measure)) - (println (str "error reading idle time: " (error-message idle-measure)))) + (do (if (:success idle-measure) + (report (:out idle-measure)) + (println (str "error reading idle time: " (:error idle-measure)))) (recur (alt! (timeout delay-time) (get-idle-time) stop-chan nil))))))) @@ -103,11 +170,15 @@ ["-t" "--time-to-idle SECONDS" "Number of seconds before considering this host idle."] ["-d" "--delay-time SECONDS" "Time to wait before polling for idle time."]]) +(defn- ->screaming-snake-case [s] + "Convert kebab-case string to SCREAMING_SNAKE_CASE" + (-> s (str/replace "-" "_") (str/upper-case))) + (defn- get-key [opts k] (if-let [opt (get opts k)] [k opt] [k (System/getenv (format "WALLFLY_%s" - (-> k name ->SCREAMING_SNAKE_CASE)))])) + (-> k name ->screaming-snake-case)))])) (defn- get-args [keys args] (let [{:keys [options errors summary]} (parse-opts args cli-opts)] @@ -137,16 +208,18 @@ delay-time mqtt-username]} (get-args required-keys args) catch-shutdown (chan) - password (-> mqtt-password-file (slurp) (str/trim-newline)) + password (-> mqtt-password-file (slurp) (str/trim)) username (get-username) hostname (get-hostname) host-device (format "wallfly-%s" (get-fqdn)) - client-id (format "wallfly-%s" (rand-str 10)) - client (create-mqtt-client mqtt-broker-uri client-id mqtt-username password) - reporter (create-reporter client (Integer/parseInt time-to-idle) location username hostname host-device) + mqtt-config (create-mqtt-config mqtt-broker-uri mqtt-username password) + reporter (create-reporter mqtt-config (Integer/parseInt time-to-idle) location username hostname host-device) stop-chan (execute! (Integer/parseInt delay-time) reporter)] (.addShutdownHook (Runtime/getRuntime) (Thread. (fn [] (>!! catch-shutdown true)))) (!! stop-chan true) (System/exit 0))) + +(when (= *file* (System/getProperty "babashka.file")) + (apply -main *command-line-args*))