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.
This commit is contained in:
28
deps.edn
28
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 }
|
||||
}
|
||||
}
|
||||
|
||||
179
src/wallfly/core.clj
Normal file → Executable file
179
src/wallfly/core.clj
Normal file → Executable file
@@ -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 >!! <!! go-loop timeout alt!]]
|
||||
[clojure.string :as str :refer [trim-newline]]
|
||||
[clojure.data.json :as json]
|
||||
[clojure.string :as str]
|
||||
[cheshire.core :as json]
|
||||
[clojure.tools.cli :refer [parse-opts]]
|
||||
[clojure.pprint :refer [pprint]]
|
||||
[fudo-clojure.result :refer [success failure unwrap map-success success? error-message]]
|
||||
[camel-snake-kebab.core :refer [->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))))
|
||||
(<!! catch-shutdown)
|
||||
(>!! stop-chan true)
|
||||
(System/exit 0)))
|
||||
|
||||
(when (= *file* (System/getProperty "babashka.file"))
|
||||
(apply -main *command-line-args*))
|
||||
|
||||
Reference in New Issue
Block a user