network widget , fixed audio widget

This commit is contained in:
Acid
2026-04-09 21:03:11 -04:00
parent 8a64176960
commit 432e55f827
12 changed files with 2738 additions and 9 deletions
+88 -7
View File
@@ -55,6 +55,8 @@ Item {
property string activeTab: "outputs" // outputs, inputs, apps
onActiveTabChanged: updateHeroData()
property var peakLevels: ({})
readonly property color tabColor: {
if (activeTab === "outputs") return window.blue;
if (activeTab === "inputs") return window.mauve;
@@ -184,6 +186,17 @@ Item {
onTriggered: audioPoller.running = true
}
Process {
id: peakProc
command: ["python3", window.scriptsDir + "/get_peak_levels.py"]
running: true
stdout: SplitParser {
onRead: (line) => {
try { window.peakLevels = JSON.parse(line); } catch(e) {}
}
}
}
// -------------------------------------------------------------------------
// ANIMATIONS
// -------------------------------------------------------------------------
@@ -239,7 +252,32 @@ Item {
Behavior on color { ColorAnimation { duration: 800 } }
}
ColumnLayout {
// Cog button — top right corner
Item {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 14
width: 28; height: 28
z: 10
Image {
anchors.centerIn: parent
source: Qt.resolvedUrl("cog.svg")
width: 16; height: 16
opacity: cogMa.containsMouse ? 1.0 : 0.35
Behavior on opacity { NumberAnimation { duration: 150 } }
}
MouseArea {
id: cogMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: Quickshell.execDetached(["pavucontrol"])
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 25
spacing: 20
@@ -600,13 +638,13 @@ Item {
spacing: 8
Text {
font.family: "Iosevka Nerd Font"; font.pixelSize: 18
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.text : window.subtext0)
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.subtext0 : window.overlay0)
text: icon
Behavior on color { ColorAnimation { duration: 200 } }
}
Text {
font.family: "JetBrains Mono"; font.weight: Font.Black; font.pixelSize: 13
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.text : window.subtext0)
color: window.activeTab === tabId ? window.crust : (tabMa.containsMouse ? window.subtext0 : window.overlay0)
text: label
Behavior on color { ColorAnimation { duration: 200 } }
}
@@ -686,7 +724,7 @@ Item {
// Dynamic Height: The active hero element collapses its bottom slider row
property bool isActiveNode: model.is_default && window.activeTab !== "apps"
height: isActiveNode ? 60 : 100
height: isActiveNode ? 76 : 116
Behavior on height { NumberAnimation { duration: 400; easing.type: Easing.OutQuint } }
radius: 14
@@ -757,7 +795,50 @@ Item {
}
}
// Bottom row: Custom Slider & Mute (Hides if it's the active node)
// VU level meter
Item {
id: vuMeter
Layout.fillWidth: true
height: 6
property real rawPeak: {
let key = (window.activeTab === "outputs" ? "sink_"
: window.activeTab === "inputs" ? "source_"
: "app_") + model.id;
return window.peakLevels[key] || 0.0;
}
property real displayPeak: 0.0
onRawPeakChanged: {
if (rawPeak > displayPeak) {
peakDecay.stop();
displayPeak = rawPeak;
peakDecay.restart();
}
}
NumberAnimation {
id: peakDecay
target: vuMeter; property: "displayPeak"
to: 0.0; duration: 900; easing.type: Easing.OutCubic
}
Rectangle {
anchors.fill: parent; radius: 3
color: isActiveNode ? "#20000000" : "#0dffffff"
}
Rectangle {
height: parent.height; radius: 3
width: vuMeter.width * vuMeter.displayPeak
color: model.mute ? window.surface2
: isActiveNode ? Qt.darker(window.tabColor, 1.3)
: window.tabColor
opacity: model.mute ? 0.4 : 0.9
Behavior on color { ColorAnimation { duration: 300 } }
}
}
// Bottom row: Custom Slider & Mute (Hides if it's the active node)
RowLayout {
Layout.fillWidth: true
spacing: 15
@@ -832,8 +913,8 @@ Item {
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: model.mute ? window.surface2 : window.tabColor; Behavior on color { ColorAnimation { duration: 300 } } }
GradientStop { position: 1.0; color: model.mute ? Qt.lighter(window.surface2, 1.15) : Qt.lighter(window.tabColor, 1.25); Behavior on color { ColorAnimation { duration: 300 } } }
GradientStop { position: 0.0; color: model.mute ? window.surface2 : (window.activeTab === "apps" ? window.tabColor : window.surface2); Behavior on color { ColorAnimation { duration: 300 } } }
GradientStop { position: 1.0; color: model.mute ? Qt.lighter(window.surface2, 1.15) : (window.activeTab === "apps" ? Qt.lighter(window.tabColor, 1.25) : Qt.lighter(window.surface2, 1.2)); Behavior on color { ColorAnimation { duration: 300 } } }
}
}
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Toggle the audio widget
WIDGET="$(dirname "$(realpath "$0")")/audioWidget.qml"
if pgrep -f ".*audioWidget.qml" > /dev/null 2>&1; then
pkill -f ".*audioWidget.qml" > /dev/null 2>&1
else
quickshell -p "$WIDGET" &
fi
+2
View File
@@ -21,4 +21,6 @@ FloatingWindow {
sequence: "Escape"
onActivated: Qt.quit()
}
onVisibleChanged: if (!visible) Qt.quit()
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="31.371094" height="31.371094" viewBox="0 0 31.371094 31.371093"><path d="M13.64 0v2.602c-.945.146-1.858.395-2.726.732L9.611 1.08 6.072 3.123l1.305 2.262a13 13 0 0 0-1.992 1.992L3.123 6.072 1.078 9.613l2.256 1.301a13 13 0 0 0-.732 2.727H0v4.088h2.602c.146.946.395 1.858.732 2.726l-2.256 1.303 2.045 3.54 2.262-1.306c.59.735 1.257 1.402 1.992 1.994l-1.305 2.26 3.54 2.045 1.302-2.256c.868.337 1.781.59 2.727.736v2.6h4.088v-2.6c.945-.149 1.857-.399 2.726-.736l1.3 2.256 3.542-2.045-1.303-2.26c.733-.592 1.4-1.26 1.992-1.994l2.26 1.307 2.045-3.541-2.256-1.303c.336-.868.588-1.78.735-2.726h2.601V13.64H28.77a13 13 0 0 0-.735-2.727l2.256-1.3-2.045-3.542-2.26 1.305c-.592-.735-1.26-1.4-1.994-1.992l1.305-2.262-3.541-2.043-1.3 2.254a13 13 0 0 0-2.727-.732V0Zm2.237 5.525c5.612 0 10.16 4.548 10.16 10.159s-4.548 10.162-10.16 10.162-10.16-4.55-10.16-10.162c0-5.611 4.548-10.159 10.16-10.159m0 .557c-.065 0-.128.008-.193.01v.877l-.016.002c-2.327.01-4.183 1.08-5.256 2.394-.579.707-1.1 1.808-1.18 2.854-.142 1.854.795 2.658.875 4.338.012.253.031.59-.037.8-.093.282-.565.557-.646.989-.062.318.036.546.152.914.655.704.413 1.846 1.217 2.36.496.315 1.354.105 1.902-.192-.242.886-.594 1.865-.53 2.931.27.084.692.015 1.024.037.012-.305-.035-.67.116-.835.268.117-.006.54.076.835h1.103c.032-.309-.087-.772.116-.914.246.118-.012.592.076.914h.875c.066-.253.029-.61.133-.83.006-.013.01-.031.017-.045v-1.773l-.015-.012h-.002l-.502-.324.502-1.353.017-.045V6.97c2.325.01 4.183 1.08 5.256 2.394.579.707 1.1 1.81 1.182 2.854.141 1.854-.797 2.658-.877 4.338-.012.253-.031.59.037.8.092.282.567.557.648.989.06.318-.038.547-.154.914-.655.704-.413 1.846-1.217 2.361-.496.315-1.354.104-1.902-.193.242.886.596 1.865.533 2.931-.27.084-.697.015-1.027.037-.012-.305.035-.67-.116-.835-.266.117.007.54-.074.835h-1.103c-.034-.309.086-.772-.116-.914-.248.118.012.592-.076.914h-.875c-.068-.253-.034-.61-.136-.83v1.715c.065.002.128.008.193.008 5.295 0 9.605-4.308 9.605-9.605 0-5.295-4.31-9.602-9.605-9.602m-.191 15.654.502-.324-.502-1.353Zm-2.834-4.963a1.857 1.857 0 1 1-.004 3.715 1.857 1.857 0 0 1 .004-3.715m6.468.66c-.662.377-1.363.717-2.283.837-.203.202-.553.257-.607.609.869.53 1.894 1.091 3.234 1.027.472-.39.75-.977.723-1.865-.228-.33-.652-.466-1.067-.607" style="fill:#fff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:1.33333"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Monitors each sink-input (app) and source individually via pacat.
Derives sink peak = max of all apps routed to that sink.
Emits JSON lines at ~20fps. Dies with parent via PR_SET_PDEATHSIG.
"""
import subprocess, struct, json, threading, sys, math, time, signal, ctypes
try:
ctypes.CDLL("libc.so.6").prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG
except Exception:
pass
RATE = 44100
CHANNELS = 2
CHUNK_MS = 50
BYTES_PS = 2
chunk_bytes = int(RATE * CHUNK_MS / 1000) * CHANNELS * BYTES_PS
levels = {} # key -> float 0..1
running = {} # key -> True
procs = {} # key -> Popen
app_sinks = {} # app_N -> sink index
lock = threading.Lock()
def cleanup(*_):
with lock:
for p in procs.values():
try: p.terminate()
except: pass
sys.exit(0)
signal.signal(signal.SIGTERM, cleanup)
signal.signal(signal.SIGINT, cleanup)
def db_scale(peak):
if peak < 0.0001:
return 0.0
return max(0.0, min(1.0, (20.0 * math.log10(peak) + 60.0) / 60.0))
def monitor(key, cmd):
with lock:
running[key] = True
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
with lock:
procs[key] = proc
while True:
data = proc.stdout.read(chunk_bytes)
if not data:
break
with lock:
if key not in running:
break
n = len(data) // BYTES_PS
s = struct.unpack_from(f'<{n}h', data)
peak = max(abs(x) for x in s) / 32768.0
with lock:
levels[key] = db_scale(peak)
proc.terminate()
proc.wait()
except Exception:
pass
with lock:
running.pop(key, None)
levels.pop(key, None)
procs.pop(key, None)
def spawn(key, cmd):
threading.Thread(target=monitor, args=(key, cmd), daemon=True).start()
def pactl_json(obj):
try:
return json.loads(subprocess.check_output(
['pactl', '-f', 'json', 'list', obj], stderr=subprocess.DEVNULL).decode())
except Exception:
return []
def scan_devices():
sources = pactl_json('sources')
apps = pactl_json('sink-inputs')
sinks = pactl_json('sinks')
wanted = set()
# Sources (microphones etc, not monitors)
for s in sources:
if '.monitor' in s['name']:
continue
key = f"source_{s['index']}"
wanted.add(key)
with lock:
if key not in running:
spawn(key, ['pacat', '-r', '-d', s['name'],
'--channels=2', '--format=s16le', f'--rate={RATE}'])
# Apps — one pacat per sink-input
for a in apps:
key = f"app_{a['index']}"
wanted.add(key)
with lock:
app_sinks[key] = a['sink']
if key not in running:
spawn(key, ['pacat', '-r', f'--monitor-stream={a["index"]}',
'--channels=2', '--format=s16le', f'--rate={RATE}'])
# Remove stale entries
with lock:
for key in list(running.keys()):
if key not in wanted:
p = procs.get(key)
if p:
try: p.terminate()
except: pass
del running[key]
for key in list(app_sinks.keys()):
if key not in wanted:
del app_sinks[key]
# Ensure every sink has an entry (even if no apps)
with lock:
for s in sinks:
sink_key = f"sink_{s['index']}"
if sink_key not in levels:
levels[sink_key] = 0.0
def emit_loop():
while True:
with lock:
snapshot = dict(levels)
# Derive sink peaks from connected app peaks
sink_peaks = {}
for app_key, sink_idx in app_sinks.items():
sk = f"sink_{sink_idx}"
v = snapshot.get(app_key, 0.0)
if v > sink_peaks.get(sk, 0.0):
sink_peaks[sk] = v
snapshot.update(sink_peaks)
sys.stdout.write(json.dumps(snapshot) + '\n')
sys.stdout.flush()
time.sleep(0.05)
def rescan_loop():
while True:
scan_devices()
time.sleep(2)
threading.Thread(target=rescan_loop, daemon=True).start()
threading.Thread(target=emit_loop, daemon=True).start()
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
cleanup()
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
SCAN_LOG="$HOME/.cache/bt_scan.log"
PID_FILE="$HOME/.cache/bt_scan_pid"
CACHE_DIR="/tmp/quickshell_network_cache"
mkdir -p "$CACHE_DIR"
get_icon() {
local type=$(echo "$1" | tr '[:upper:]' '[:lower:]')
local name=$(echo "$2" | tr '[:upper:]' '[:lower:]')
if [[ "$type" == *"headset"* ]] || [[ "$type" == *"headphone"* ]] || [[ "$name" == *"headphone"* ]] || [[ "$name" == *"buds"* ]] || [[ "$name" == *"pods"* ]]; then echo "🎧"
elif [[ "$type" == *"audio"* ]] || [[ "$type" == *"speaker"* ]] || [[ "$type" == *"card"* ]] || [[ "$name" == *"speaker"* ]]; then echo "蓼"
elif [[ "$type" == *"phone"* ]] || [[ "$name" == *"phone"* ]] || [[ "$name" == *"iphone"* ]] || [[ "$name" == *"android"* ]]; then echo ""
elif [[ "$type" == *"mouse"* ]] || [[ "$name" == *"mouse"* ]]; then echo ""
elif [[ "$type" == *"keyboard"* ]] || [[ "$name" == *"keyboard"* ]]; then echo ""
elif [[ "$type" == *"controller"* ]] || [[ "$name" == *"controller"* ]]; then echo ""
else echo ""
fi
}
get_audio_profile() {
local mac="$1"
local mac_us=$(echo "$mac" | tr ':' '_')
# Grab the block of text containing the specific device's card and extract its Active Profile
local active=$(pactl list cards 2>/dev/null | grep -i -A 20 "Name:.*$mac_us" | grep -i "Active Profile:" | head -n 1 | cut -d: -f2 | xargs)
if [[ -z "$active" || "$active" == "off" ]]; then echo "None"; return; fi
local desc="Connected"
if [[ "$active" == *"a2dp"* ]]; then desc="Hi-Fi (A2DP)"; fi
if [[ "$active" == *"headset"* || "$active" == *"hfp"* ]]; then desc="Headset (HFP)"; fi
echo "$desc"
}
get_status() {
power="off"
if bluetoothctl show | grep -q "Powered: yes"; then power="on"; fi
connected_json="[]"
devices_json="[]"
if [ "$power" == "on" ]; then
paired_macs=$(bluetoothctl devices Paired | cut -d ' ' -f 2)
mapfile -t devices < <(bluetoothctl devices)
connected_list_objs=()
paired_list_objs=()
discovered_list_objs=()
# Get all connected devices
mapfile -t connected_info_lines < <(bluetoothctl devices Connected)
# Extract just the MACs of connected devices for filtering the main list later
connected_macs=$(echo "${connected_info_lines[@]}" | awk '{for(i=1;i<=NF;i++) if($i~/^([0-9A-F]{2}:){5}[0-9A-F]{2}$/) print $i}')
for c_line in "${connected_info_lines[@]}"; do
if [ -z "$c_line" ]; then continue; fi
connected_mac=$(echo "$c_line" | cut -d ' ' -f 2)
CACHE_FILE="$CACHE_DIR/bt_stat_${connected_mac//:/_}"
# Profile, Name, and Icon do not change dynamically. Calculate ONCE.
if [ -f "$CACHE_FILE" ]; then
source "$CACHE_FILE"
else
name=$(echo "$c_line" | cut -d ' ' -f 3-)
info=$(bluetoothctl info "$connected_mac")
icon_type=$(echo "$info" | grep "Icon:" | cut -d: -f2 | xargs)
icon=$(get_icon "$icon_type" "$name")
profile=$(get_audio_profile "$connected_mac")
echo "CACHE_NAME=\"$name\"" > "$CACHE_FILE"
echo "CACHE_ICON=\"$icon\"" >> "$CACHE_FILE"
echo "CACHE_PROFILE=\"$profile\"" >> "$CACHE_FILE"
CACHE_NAME="$name"
CACHE_ICON="$icon"
CACHE_PROFILE="$profile"
fi
# Dynamically fetch ONLY the battery since it changes
# Strictly extract whatever is inside the parenthesis (e.g. 100 from "0x64 (100)")
bat=$(bluetoothctl info "$connected_mac" | awk '/Battery Percentage:/ {gsub(/.*\(/,""); gsub(/\).*/,""); print}')
[ -z "$bat" ] && bat=$(bluetoothctl info "$connected_mac" | grep -i "Battery Percentage" | awk '{print $NF}' | tr -d '()')
[ -z "$bat" ] || [ "$bat" == "?" ] && bat="0"
obj=$(jq -n -c \
--arg id "$connected_mac" \
--arg name "$CACHE_NAME" \
--arg mac "$connected_mac" \
--arg icon "$CACHE_ICON" \
--arg bat "$bat" \
--arg profile "$CACHE_PROFILE" \
'{id: $id, name: $name, mac: $mac, icon: $icon, battery: $bat, profile: $profile}')
connected_list_objs+=("$obj")
done
if [ ${#connected_list_objs[@]} -gt 0 ]; then
connected_json=$(printf '%s\n' "${connected_list_objs[@]}" | jq -s -c '.')
fi
for line in "${devices[@]}"; do
if [ -z "$line" ]; then continue; fi
mac=$(echo "$line" | cut -d ' ' -f 2)
# Skip if this MAC is already in the connected list
if echo "$connected_macs" | grep -q "$mac"; then continue; fi
name=$(echo "$line" | cut -d ' ' -f 3-)
icon=$(get_icon "unknown" "$name")
if echo "$paired_macs" | grep -q "$mac"; then
action="Connect"
obj=$(jq -n -c --arg id "$mac" --arg name "$name" --arg mac "$mac" --arg icon "$icon" --arg action "$action" '{id: $id, name: $name, mac: $mac, icon: $icon, action: $action}')
paired_list_objs+=("$obj")
else
action="Pair"
obj=$(jq -n -c --arg id "$mac" --arg name "$name" --arg mac "$mac" --arg icon "$icon" --arg action "$action" '{id: $id, name: $name, mac: $mac, icon: $icon, action: $action}')
discovered_list_objs+=("$obj")
fi
done
all_objs=("${paired_list_objs[@]}" "${discovered_list_objs[@]}")
if [ ${#all_objs[@]} -gt 0 ]; then
devices_json=$(printf '%s\n' "${all_objs[@]}" | jq -s -c '.')
fi
if [ -z "$devices_json" ]; then devices_json="[]"; fi
fi
jq -n -c \
--arg power "$power" \
--argjson connected "${connected_json}" \
--argjson devices "${devices_json:-[]}" \
'{power: $power, connected: $connected, devices: $devices}'
}
toggle_power() {
if bluetoothctl show | grep -q "Powered: yes"; then
bluetoothctl power off
else
bluetoothctl power on
fi
sleep 0.5
}
connect_dev() {
local mac="$1"
if [ -f "$PID_FILE" ]; then kill -STOP $(cat "$PID_FILE") 2>/dev/null; fi
bluetoothctl trust "$mac" > /dev/null 2>&1
bluetoothctl connect "$mac"
if [ -f "$PID_FILE" ]; then kill -CONT $(cat "$PID_FILE") 2>/dev/null; fi
}
disconnect_dev() {
local mac="$1"
# Remove cache so a fresh connect regenerates the profile
rm -f "/tmp/quickshell_network_cache/bt_stat_${mac//:/_}" 2>/dev/null
bluetoothctl disconnect "$mac"
}
cmd="$1"
case $cmd in
--status) get_status ;;
--toggle) toggle_power ;;
--connect) connect_dev "$2" ;;
--disconnect) disconnect_dev "$2" ;;
esac
@@ -0,0 +1,23 @@
import QtQuick
import Quickshell
import Quickshell.Io
FloatingWindow {
id: root
title: "qs-network"
objectName: "quickshellWidget"
color: "transparent"
visible: true
implicitWidth: 900
implicitHeight: 700
NetworkPopup {
anchors.fill: parent
}
Shortcut {
sequence: "Escape"
onActivated: Qt.quit()
}
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Toggle the network widget: launch if not running, kill if running.
WIDGET="$(dirname "$(realpath "$0")")/networkWidget.qml"
if pgrep -f "quickshell -p.*networkWidget.qml" > /dev/null 2>&1; then
pkill -f "quickshell -p.*networkWidget.qml"
else
quickshell -p "$WIDGET" &
fi
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Check if WiFi is enabled
POWER=$(nmcli radio wifi)
if [[ "$POWER" == "disabled" ]]; then
echo '{ "power": "off", "connected": null, "networks": [] }'
exit 0
fi
# Function to get icon based on signal strength
get_icon() {
local signal=$1
if [[ $signal -ge 80 ]]; then echo "󰤨";
elif [[ $signal -ge 60 ]]; then echo "󰤥";
elif [[ $signal -ge 40 ]]; then echo "󰤢";
elif [[ $signal -ge 20 ]]; then echo "󰤟";
else echo "󰤯"; fi
}
CACHE_DIR="/tmp/quickshell_network_cache"
mkdir -p "$CACHE_DIR"
# Get current connection details
CURRENT_RAW=$(nmcli -t -f active,ssid,signal,security device wifi | grep "^yes")
if [[ -n "$CURRENT_RAW" ]]; then
IFS=':' read -r active ssid signal security <<< "$CURRENT_RAW"
icon=$(get_icon "$signal")
# Safe filename for cache
SAFE_SSID="${ssid//[^a-zA-Z0-9]/_}"
CACHE_FILE="$CACHE_DIR/wifi_$SAFE_SSID"
# Load cached IP and FREQ if they exist to prevent blocking
if [ -f "$CACHE_FILE" ]; then
source "$CACHE_FILE"
fi
# If cache is missing, fetch the expensive stats once and save them
if [ -z "$IP" ] || [ "$IP" == "No IP" ] || [ -z "$FREQ" ]; then
IFACE=$(nmcli -t -f DEVICE,TYPE d | awk -F: '$2=="wifi"{print $1;exit}')
IP=$(ip -4 addr show dev "$IFACE" 2>/dev/null | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -n1)
[ -z "$IP" ] && IP="No IP"
FREQ=$(iw dev "$IFACE" link 2>/dev/null | grep freq | awk '{print $2}')
[ -n "$FREQ" ] && FREQ="${FREQ} MHz" || FREQ="Unknown"
echo "IP=\"$IP\"" > "$CACHE_FILE"
echo "FREQ=\"$FREQ\"" >> "$CACHE_FILE"
fi
CONNECTED_JSON=$(jq -n \
--arg id "$ssid" \
--arg ssid "$ssid" \
--arg icon "$icon" \
--arg signal "$signal" \
--arg security "$security" \
--arg ip "$IP" \
--arg freq "$FREQ" \
'{id: $id, ssid: $ssid, icon: $icon, signal: $signal, security: $security, ip: $ip, freq: $freq}')
else
CONNECTED_JSON="null"
fi
# Get available networks INSTANTLY using --rescan no
NETWORKS_JSON=$(nmcli -t -f active,ssid,signal,security device wifi list --rescan no | \
awk -F: '!seen[$2]++ && $2 != "" && $1 != "yes" {print $2":"$3":"$4}' | \
head -n 24 | \
while IFS=':' read -r ssid signal security; do
icon=$(get_icon "$signal")
jq -n \
--arg id "$ssid" \
--arg ssid "$ssid" \
--arg icon "$icon" \
--arg signal "$signal" \
--arg security "$security" \
'{id: $id, ssid: $ssid, icon: $icon, signal: $signal, security: $security}'
done | jq -s '.')
echo $(jq -n \
--arg power "on" \
--argjson connected "${CONNECTED_JSON:-null}" \
--argjson networks "${NETWORKS_JSON:-[]}" \
'{power: $power, connected: $connected, networks: $networks}')
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Outputs { "networking": "enabled"|"disabled", "interfaces": [...] }
prefix_to_mask() {
local p=$1
local parts=()
for (( i=0; i<4; i++ )); do
local bits=$(( p > 8 ? 8 : p < 0 ? 0 : p ))
parts+=("$(( bits == 0 ? 0 : 256 - (1 << (8 - bits)) ))")
(( p -= 8 ))
done
echo "${parts[0]}.${parts[1]}.${parts[2]}.${parts[3]}"
}
broadcast_addr() {
local ip=$1 prefix=$2
IFS='.' read -r a b c d <<< "$ip"
local mask=$(( 0xFFFFFFFF << (32 - prefix) & 0xFFFFFFFF ))
local ipi=$(( (a<<24)|(b<<16)|(c<<8)|d ))
local bc=$(( ipi | (~mask & 0xFFFFFFFF) ))
echo "$(( (bc>>24)&0xFF )).$(( (bc>>16)&0xFF )).$(( (bc>>8)&0xFF )).$(( bc&0xFF ))"
}
json_str() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'; }
interfaces=()
while IFS= read -r device; do
[[ -z "$device" ]] && continue
show=$(nmcli -t device show "$device" 2>/dev/null)
profile=$(echo "$show" | grep "^GENERAL.CONNECTION:" | cut -d: -f2-)
mac=$(echo "$show" | grep "^GENERAL.HWADDR:" | cut -d: -f2-)
driver=$(echo "$show" | grep "^GENERAL.DRIVER:" | cut -d: -f2-)
ip_cidr=$(echo "$show" | grep "^IP4.ADDRESS\[1\]:" | cut -d: -f2-)
gateway=$(echo "$show" | grep "^IP4.GATEWAY:" | cut -d: -f2-)
dns1=$(echo "$show" | grep "^IP4.DNS\[1\]:" | cut -d: -f2-)
ipv6_cidr=$(echo "$show" | grep "^IP6.ADDRESS\[1\]:" | cut -d: -f2-)
ipv6_gw=$(echo "$show" | grep "^IP6.GATEWAY:" | cut -d: -f2-)
ipv6_dns=$(echo "$show" | grep "^IP6.DNS\[1\]:" | cut -d: -f2-)
speed=$(cat /sys/class/net/"$device"/speed 2>/dev/null)
[[ -z "$profile" || "$profile" == "--" ]] && profile="$device"
[[ -z "$speed" || "$speed" == "-1" ]] && speed=""
speed_label=""
if [[ -n "$speed" ]]; then
if (( speed >= 1000 )); then speed_label="$(( speed / 1000 )) Gbps"
else speed_label="${speed} Mbps"; fi
fi
ip_clean="" subnet="" broadcast=""
if [[ -n "$ip_cidr" ]]; then
ip_clean="${ip_cidr%%/*}"
prefix="${ip_cidr##*/}"
subnet=$(prefix_to_mask "$prefix")
broadcast=$(broadcast_addr "$ip_clean" "$prefix")
fi
is_connected=false
[[ -n "$ip_clean" ]] && is_connected=true
if $is_connected; then
subtitle="Connected${speed_label:+ • $speed_label}"
cmd_str="nmcli device disconnect $device"
is_actionable=true
else
subtitle="Disconnected"
cmd_str="nmcli device connect $device"
is_actionable=true
fi
obj="{\"id\":\"$(json_str "$device")\",\"name\":\"$(json_str "$profile")\",\"icon\":\"󰈀\""
obj+=",\"ip\":\"$(json_str "$ip_clean")\",\"subnet\":\"$(json_str "$subnet")\""
obj+=",\"broadcast\":\"$(json_str "$broadcast")\",\"gateway\":\"$(json_str "$gateway")\",\"dns\":\"$(json_str "$dns1")\""
obj+=",\"ipv6\":\"$(json_str "$ipv6_cidr")\",\"ipv6gw\":\"$(json_str "$ipv6_gw")\",\"ipv6dns\":\"$(json_str "$ipv6_dns")\""
obj+=",\"mac\":\"$(json_str "$mac")\",\"driver\":\"$(json_str "$driver")\",\"speed\":\"$(json_str "$speed_label")\""
obj+=",\"action\":\"$(json_str "$subtitle")\",\"isConnected\":$is_connected"
obj+=",\"isInfoNode\":false,\"isActionable\":$is_actionable,\"cmdStr\":\"$(json_str "$cmd_str")\"}"
interfaces+=("$obj")
done < <(nmcli -t -f DEVICE,TYPE device status 2>/dev/null | awk -F: '$2=="ethernet"{print $1}')
net_state=$(nmcli networking 2>/dev/null | tr -d '[:space:]')
[[ "$net_state" != "enabled" ]] && net_state="disabled"
joined=$(IFS=,; echo "${interfaces[*]}")
echo "{\"networking\":\"$net_state\",\"interfaces\":[$joined]}"
+2 -2
View File
@@ -55,7 +55,7 @@
"custom/updates": {
"return-type": "json",
"text": "{text}  ",
"interval": 300,
"interval": 3600,
"exec": "$HOME/.config/waybar/scripts/dnf-updates.sh",
"tooltip": "{tooltip} "
},
@@ -63,7 +63,7 @@
"clock#1": {
"format": "{:%a %I:%M %m-%d}",
"on-click": "bash $HOME/.config/waybar/scripts/calendar.sh",
"on-click": "bash $HOME/.config/waybar/scripts/calendar.sh > /dev/null 2>&1",
"tooltip": false
},