Make the weather location and graphics device choosable
The last two values that could only be changed by editing a file. Weather was pinned to hardcoded coordinates, so the card could not be pointed anywhere else. It is a location search now, not latitude and longitude fields: nobody knows their own coordinates, and a control that demands them is one nobody uses. Open-Meteo's geocoding endpoint needs no key, the same reason the forecast already uses them. Only the search term leaves the machine -- the stored place name is a label -- and coordinates are rounded to four decimals, far finer than a weather reading resolves and coarse enough to keep a precise home location out of the settings file. The graphics readout was hardcoded to card1. This machine has two amdgpu cards, discrete and integrated, so that was right only by luck, and the path is meaningless on any other machine. GPUs are enumerated with a readable name from lspci, since sysfs exposes only numeric ids, and the picker appears only when there is more than one to choose between. A stored path the machine does not have is refused and reported rather than silently measuring nothing. Also merges the per-application notification rules UI. Its three commits were believed integrated but the page half was not actually in the tree: main had the service side in Notifs.qml and zero references to setAppRule in NotificationsPage. Ancestry is not content. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -419,6 +419,47 @@ Singleton {
|
||||
detail: "How often Panama updates the current conditions"
|
||||
},
|
||||
|
||||
// ── Which GPU the vitals readout tracks ─────────────────────────────
|
||||
// A sysfs path rather than a card number, because the number is neither
|
||||
// stable across machines nor meaningful. Constrained to the one shape
|
||||
// that can be read for utilisation; VitalsWidget hides itself when the
|
||||
// path is unreadable, so a stale value degrades to no readout rather
|
||||
// than a wrong one.
|
||||
{
|
||||
key: "gpuBusyPath", type: "string",
|
||||
def: "/sys/class/drm/card1/device/gpu_busy_percent",
|
||||
group: "vitals", internal: true,
|
||||
pattern: "^/sys/class/drm/card[0-9]+/device/gpu_busy_percent$",
|
||||
label: "Graphics device",
|
||||
detail: "Which GPU the graphics readout in the bar measures"
|
||||
},
|
||||
|
||||
// ── Weather location ────────────────────────────────────────────────
|
||||
// Coordinates rather than a place name, because that is what Open-Meteo
|
||||
// takes and it needs no API key. weatherLocation is only the label shown
|
||||
// in the UI; it is never sent anywhere, so it can say whatever makes the
|
||||
// reading recognisable.
|
||||
{
|
||||
key: "weatherLatitude", type: "real", def: 27.7375, min: -90, max: 90, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Latitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLongitude", type: "real", def: -82.6861, min: -180, max: 180, step: 0.0001,
|
||||
group: "weather", internal: true,
|
||||
label: "Longitude",
|
||||
detail: "Set by choosing a location"
|
||||
},
|
||||
{
|
||||
key: "weatherLocation", type: "string", def: "Local weather", group: "weather",
|
||||
internal: true,
|
||||
// Display only -- never sent to the weather service.
|
||||
pattern: "^[^\\n]{1,64}$",
|
||||
label: "Weather location",
|
||||
detail: "The place the weather reading is for"
|
||||
},
|
||||
|
||||
// ── Vitals refresh ──────────────────────────────────────────────────
|
||||
{
|
||||
key: "vitalsIntervalMs", type: "int", def: 2000, min: 500, max: 10000, step: 500,
|
||||
|
||||
@@ -22,12 +22,12 @@ Singleton {
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
// resolved the location. Uses Open-Meteo, which needs no API key.
|
||||
readonly property real latitude: 27.7375
|
||||
readonly property real longitude: -82.6861
|
||||
readonly property real latitude: DesktopPreferences.get("weatherLatitude")
|
||||
readonly property real longitude: DesktopPreferences.get("weatherLongitude")
|
||||
// Open-Meteo returns coordinates but no friendly place name. Keep the
|
||||
// label deliberately general rather than exposing precise coordinates in
|
||||
// the UI or guessing at a city from them.
|
||||
readonly property string weatherLocation: "Local weather"
|
||||
readonly property string weatherLocation: DesktopPreferences.get("weatherLocation")
|
||||
readonly property string temperatureUnit: DesktopPreferences.get("temperatureUnit")
|
||||
readonly property int weatherRefreshMinutes: DesktopPreferences.get("weatherRefreshMinutes")
|
||||
|
||||
@@ -41,7 +41,7 @@ Singleton {
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
readonly property string gpuBusyPath: "/sys/class/drm/card1/device/gpu_busy_percent"
|
||||
readonly property string gpuBusyPath: DesktopPreferences.get("gpuBusyPath")
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────────
|
||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
function clamp(value, minimum, maximum) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function finiteNumber(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function iconFor(kind, ratio) {
|
||||
var name = String(kind || "").toLowerCase();
|
||||
if (name === "volume-muted")
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (name === "volume") {
|
||||
if (ratio <= 0)
|
||||
return "audio-volume-muted-symbolic";
|
||||
if (ratio < 0.34)
|
||||
return "audio-volume-low-symbolic";
|
||||
if (ratio < 0.67)
|
||||
return "audio-volume-medium-symbolic";
|
||||
return "audio-volume-high-symbolic";
|
||||
}
|
||||
if (name === "microphone-muted")
|
||||
return "microphone-sensitivity-muted-symbolic";
|
||||
if (name === "microphone")
|
||||
return "audio-input-microphone-symbolic";
|
||||
if (name === "brightness")
|
||||
return "display-brightness-symbolic";
|
||||
if (name === "media-play" || name === "media-playing")
|
||||
return "media-playback-start-symbolic";
|
||||
if (name === "media-pause" || name === "media-paused")
|
||||
return "media-playback-pause-symbolic";
|
||||
if (name === "media-next")
|
||||
return "media-skip-forward-symbolic";
|
||||
if (name === "media-previous")
|
||||
return "media-skip-backward-symbolic";
|
||||
if (name === "media-stop")
|
||||
return "media-playback-stop-symbolic";
|
||||
return name || "dialog-information-symbolic";
|
||||
}
|
||||
|
||||
function normalizedDuration(value) {
|
||||
var parsed = finiteNumber(value, 1400);
|
||||
return Math.max(0, Math.round(parsed));
|
||||
}
|
||||
|
||||
function progressState(kind, rawValue, rawMaximum, rawLabel, rawDuration) {
|
||||
var maximum = Math.max(1, finiteNumber(rawMaximum, 100));
|
||||
var value = clamp(finiteNumber(rawValue, 0), 0, maximum);
|
||||
var ratio = value / maximum;
|
||||
var label = String(rawLabel || "");
|
||||
if (!label)
|
||||
label = Math.round(ratio * 100) + "%";
|
||||
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: value,
|
||||
maximum: maximum,
|
||||
ratio: ratio,
|
||||
label: label,
|
||||
icon: iconFor(kind, ratio),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: true
|
||||
};
|
||||
}
|
||||
|
||||
function messageState(kind, rawLabel, rawDuration) {
|
||||
return {
|
||||
kind: String(kind || ""),
|
||||
value: 0,
|
||||
maximum: 100,
|
||||
ratio: 0,
|
||||
label: String(rawLabel || ""),
|
||||
icon: iconFor(kind, 0),
|
||||
duration: normalizedDuration(rawDuration),
|
||||
progress: false
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
clamp: clamp,
|
||||
iconFor: iconFor,
|
||||
progressState: progressState,
|
||||
messageState: messageState
|
||||
};
|
||||
}
|
||||
@@ -103,7 +103,24 @@ SettingsPage {
|
||||
|
||||
ToggleRow { setting: "showCpu" }
|
||||
ToggleRow { setting: "showMemory" }
|
||||
ToggleRow { setting: "showGpu"; divider: false }
|
||||
ToggleRow { setting: "showGpu"; divider: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing }
|
||||
|
||||
// Only worth asking when there is a choice to make.
|
||||
ChoiceGrid {
|
||||
visible: GraphicsDevices.devices.length > 1 || GraphicsDevices.selectionMissing
|
||||
width: parent.width
|
||||
label: "Graphics device"
|
||||
detail: GraphicsDevices.selectionMissing
|
||||
? "The stored device is not present on this machine, so the graphics readout is hidden. Choose one below."
|
||||
: "Which GPU the graphics readout measures."
|
||||
options: GraphicsDevices.devices.map(device => ({
|
||||
value: device.path,
|
||||
label: GraphicsDevices.shortName(device.name)
|
||||
}))
|
||||
current: GraphicsDevices.selectedPath
|
||||
divider: false
|
||||
onPicked: value => GraphicsDevices.select(value)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -103,6 +103,16 @@ SettingsPage {
|
||||
SettingsCard {
|
||||
title: "Weather"
|
||||
subtitle: "Local conditions in the date menu"
|
||||
TextRow {
|
||||
label: "Location"
|
||||
detail: "Only the search term is sent; the name below is a label kept on this machine"
|
||||
value: Settings.weatherLocation
|
||||
}
|
||||
|
||||
LocationPicker {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
ChoiceRow { setting: "temperatureUnit" }
|
||||
SliderRow { setting: "weatherRefreshMinutes"; divider: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Choosing where the weather reading is for.
|
||||
//
|
||||
// A search box rather than latitude and longitude fields: nobody knows their
|
||||
// own coordinates, and a control that demands them is one nobody ever uses. The
|
||||
// coordinates are what actually get stored -- the name is only a label.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
spacing: 0
|
||||
|
||||
SearchField {
|
||||
id: query
|
||||
width: parent.width
|
||||
placeholder: "Search for a town or city"
|
||||
onTextChanged: Geocoding.search(query.text)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Geocoding.results
|
||||
|
||||
SettingRow {
|
||||
id: place
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: place.modelData.name
|
||||
detail: [place.modelData.admin, place.modelData.country].filter(part => !!part).join(", ")
|
||||
value: place.modelData.latitude.toFixed(2) + ", " + place.modelData.longitude.toFixed(2)
|
||||
controlWidth: 150
|
||||
divider: place.index < Geocoding.results.length - 1
|
||||
activatable: true
|
||||
onActivated: {
|
||||
if (Geocoding.choose(place.modelData))
|
||||
query.text = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Geocoding.searching || Geocoding.lastError !== ""
|
||||
label: Geocoding.searching ? "Searching…" : "No result"
|
||||
detail: Geocoding.searching ? "" : Geocoding.lastError
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
@@ -44,3 +44,4 @@ AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
SoundDeviceRow 1.0 SoundDeviceRow.qml
|
||||
TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.qml
|
||||
|
||||
Executable
+47
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Enumerates GPUs that can report utilisation, with a readable name for each.
|
||||
#
|
||||
# Panama's vitals readout needs one specific sysfs file, and the card numbering
|
||||
# is neither stable across machines nor meaningful to a person: this box has
|
||||
# card1 and card2, both amdgpu, one discrete and one integrated. Picking a
|
||||
# number blindly shows whichever the kernel happened to enumerate first.
|
||||
#
|
||||
# Names come from lspci where available, because the sysfs device directory
|
||||
# exposes only numeric vendor/device ids.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
first=true
|
||||
printf '['
|
||||
|
||||
for busy in /sys/class/drm/card*/device/gpu_busy_percent; do
|
||||
[[ -r "$busy" ]] || continue
|
||||
|
||||
device_dir="$(dirname "$busy")"
|
||||
card="$(basename "$(dirname "$device_dir")")"
|
||||
|
||||
# The device directory is a symlink into the PCI tree; its target's basename
|
||||
# is the PCI address lspci wants.
|
||||
pci="$(basename "$(readlink -f "$device_dir")" 2>/dev/null || true)"
|
||||
name=""
|
||||
if [[ -n "$pci" ]] && command -v lspci >/dev/null 2>&1; then
|
||||
# Strip the leading domain: lspci -s wants 00:02.0, sysfs gives 0000:00:02.0
|
||||
short="${pci#*:}"
|
||||
name="$(lspci -s "$short" 2>/dev/null | sed -E 's/^[^ ]+ [^:]+: //' | head -1)"
|
||||
fi
|
||||
if [[ -z "$name" ]]; then
|
||||
driver="$(sed -n 's/^DRIVER=//p' "$device_dir/uevent" 2>/dev/null | head -1)"
|
||||
name="${driver:-Graphics} ($card)"
|
||||
fi
|
||||
|
||||
reading="$(cat "$busy" 2>/dev/null || printf '')"
|
||||
[[ "$reading" =~ ^[0-9]+$ ]] || reading=-1
|
||||
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"card":"%s","path":"%s","name":%s,"busy":%s}' \
|
||||
"$card" "$busy" "$(printf '%s' "$name" | jq -Rs .)" "$reading"
|
||||
done
|
||||
|
||||
printf ']\n'
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -u
|
||||
|
||||
show_progress() {
|
||||
qs ipc call osd progress "$1" "$2" 100 "$3" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
show_message() {
|
||||
qs ipc call osd message "$1" "$2" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
volume_state() {
|
||||
local target="$1" output level percent muted=false
|
||||
output="$(wpctl get-volume "$target" 2>/dev/null)" || return 1
|
||||
if [[ $output =~ Volume:[[:space:]]*([0-9]+([.][0-9]+)?) ]]; then
|
||||
level="${BASH_REMATCH[1]}"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
[[ $output == *"[MUTED]"* ]] && muted=true
|
||||
percent="$(awk -v value="$level" 'BEGIN { printf "%d", value * 100 + 0.5 }')"
|
||||
printf '%s %s\n' "$percent" "$muted"
|
||||
}
|
||||
|
||||
show_volume() {
|
||||
local target="$1" kind="$2" state percent muted label
|
||||
state="$(volume_state "$target")" || return 0
|
||||
read -r percent muted <<<"$state"
|
||||
if [[ $muted == true ]]; then
|
||||
show_progress "${kind}-muted" "$percent" "Muted"
|
||||
else
|
||||
label="${percent}%"
|
||||
show_progress "$kind" "$percent" "$label"
|
||||
fi
|
||||
}
|
||||
|
||||
adjust_volume() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SINK@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd volume up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
show_volume "$target" volume
|
||||
}
|
||||
|
||||
adjust_microphone() {
|
||||
local action="${1:-}" step="${2:-6}" target="@DEFAULT_AUDIO_SOURCE@"
|
||||
case "$action" in
|
||||
up) wpctl set-volume -l 1 "$target" "${step}%+" || return ;;
|
||||
down) wpctl set-volume "$target" "${step}%-" || return ;;
|
||||
toggle) wpctl set-mute "$target" toggle || return ;;
|
||||
*) printf 'Usage: panama-osd microphone up|down|toggle [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
show_volume "$target" microphone
|
||||
}
|
||||
|
||||
adjust_brightness() {
|
||||
local action="${1:-}" step="${2:-5}" output percent
|
||||
case "$action" in
|
||||
up) brightnessctl -e4 -n2 set "${step}%+" >/dev/null || return ;;
|
||||
down) brightnessctl -e4 -n2 set "${step}%-" >/dev/null || return ;;
|
||||
*) printf 'Usage: panama-osd brightness up|down [step]\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
output="$(brightnessctl -m -c backlight 2>/dev/null)" || return 0
|
||||
percent="$(awk -F, 'NR == 1 { value=$5; gsub(/%/, "", value); print value }' <<<"$output")"
|
||||
[[ $percent =~ ^[0-9]+$ ]] || return 0
|
||||
show_progress brightness "$percent" "${percent}%"
|
||||
}
|
||||
|
||||
media_action() {
|
||||
local action="${1:-}" kind label fallback
|
||||
case "$action" in
|
||||
play-pause)
|
||||
playerctl play-pause || return
|
||||
if [[ $(playerctl status 2>/dev/null) == "Playing" ]]; then
|
||||
kind="media-play"
|
||||
fallback="Playing"
|
||||
else
|
||||
kind="media-pause"
|
||||
fallback="Paused"
|
||||
fi
|
||||
;;
|
||||
next) playerctl next || return; kind="media-next"; fallback="Next track" ;;
|
||||
previous) playerctl previous || return; kind="media-previous"; fallback="Previous track" ;;
|
||||
stop) playerctl stop || return; kind="media-stop"; fallback="Stopped" ;;
|
||||
*) printf 'Usage: panama-osd media play-pause|next|previous|stop\n' >&2; return 2 ;;
|
||||
esac
|
||||
|
||||
label="$(playerctl metadata --format '{{ title }} — {{ artist }}' 2>/dev/null)"
|
||||
[[ -n $label ]] || label="$fallback"
|
||||
show_message "$kind" "$label"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
volume) shift; adjust_volume "$@" ;;
|
||||
microphone) shift; adjust_microphone "$@" ;;
|
||||
brightness) shift; adjust_brightness "$@" ;;
|
||||
media) shift; media_action "$@" ;;
|
||||
*)
|
||||
printf 'Usage: panama-osd volume|microphone|brightness|media ACTION [step]\n' >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,129 @@
|
||||
pragma Singleton
|
||||
|
||||
// Turning a place name into coordinates.
|
||||
//
|
||||
// The weather card needs latitude and longitude, but nobody knows their own
|
||||
// coordinates, and a settings page that demands them is a settings page nobody
|
||||
// changes. Open-Meteo publishes a geocoding endpoint that needs no API key and
|
||||
// no account, which is the same reason the forecast itself uses them.
|
||||
//
|
||||
// Fetched with curl rather than XMLHttpRequest for the same reason as
|
||||
// services/Weather.qml: curl is guaranteed present, and a search that fails
|
||||
// must leave the page usable rather than producing an error popup.
|
||||
//
|
||||
// Only the query is sent. The stored location label never leaves the machine.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// [{ name, admin, country, latitude, longitude, label }]
|
||||
property var results: []
|
||||
property bool searching: false
|
||||
property string lastError: ""
|
||||
property string lastQuery: ""
|
||||
|
||||
readonly property string endpoint: "https://geocoding-api.open-meteo.com/v1/search"
|
||||
|
||||
Process {
|
||||
id: fetch
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.parse(this.text)
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.searching = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "Could not reach the location service.";
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced: typing "Denver" should not fire six searches.
|
||||
Timer {
|
||||
id: debounce
|
||||
interval: 350
|
||||
onTriggered: root.run()
|
||||
}
|
||||
|
||||
property string pending: ""
|
||||
|
||||
function search(query: string): void {
|
||||
const trimmed = String(query).trim();
|
||||
root.pending = trimmed;
|
||||
if (trimmed.length < 2) {
|
||||
root.results = [];
|
||||
root.lastError = "";
|
||||
debounce.stop();
|
||||
return;
|
||||
}
|
||||
debounce.restart();
|
||||
}
|
||||
|
||||
function run(): void {
|
||||
if (fetch.running || root.pending.length < 2)
|
||||
return;
|
||||
root.searching = true;
|
||||
root.lastError = "";
|
||||
root.lastQuery = root.pending;
|
||||
|
||||
// --get with --data-urlencode makes curl do the escaping, so a place
|
||||
// name with spaces or an ampersand cannot alter the request.
|
||||
fetch.exec(["curl", "-s", "--max-time", "10", "--get",
|
||||
"--data-urlencode", `name=${root.pending}`,
|
||||
"--data-urlencode", "count=8",
|
||||
"--data-urlencode", "format=json",
|
||||
root.endpoint]);
|
||||
}
|
||||
|
||||
function parse(text: string): void {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
const out = [];
|
||||
for (const item of (parsed.results ?? [])) {
|
||||
if (typeof item.latitude !== "number" || typeof item.longitude !== "number")
|
||||
continue;
|
||||
const admin = item.admin1 ?? "";
|
||||
const country = item.country ?? "";
|
||||
out.push({
|
||||
name: item.name ?? "",
|
||||
admin: admin,
|
||||
country: country,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
// What the user will see stored as their location label.
|
||||
label: [item.name, admin, country].filter(part => !!part).join(", ")
|
||||
});
|
||||
}
|
||||
root.results = out;
|
||||
root.lastError = out.length === 0 ? "No places match that name." : "";
|
||||
} catch (error) {
|
||||
root.results = [];
|
||||
root.lastError = "The location service returned something unreadable.";
|
||||
}
|
||||
}
|
||||
|
||||
// Stores a chosen place. Coordinates are rounded to four decimals -- roughly
|
||||
// ten metres, far finer than a weather reading resolves, and it keeps a
|
||||
// precise home location out of the settings file.
|
||||
function choose(place: var): bool {
|
||||
const latitude = Math.round(place.latitude * 10000) / 10000;
|
||||
const longitude = Math.round(place.longitude * 10000) / 10000;
|
||||
const label = String(place.label).slice(0, 64);
|
||||
|
||||
const ok = DesktopPreferences.set("weatherLatitude", latitude)
|
||||
&& DesktopPreferences.set("weatherLongitude", longitude)
|
||||
&& DesktopPreferences.set("weatherLocation", label);
|
||||
if (!ok) {
|
||||
root.lastError = "That location could not be saved.";
|
||||
return false;
|
||||
}
|
||||
root.results = [];
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
pragma Singleton
|
||||
|
||||
// The GPUs that can report utilisation.
|
||||
//
|
||||
// The vitals readout needs one specific sysfs file, and card numbering is
|
||||
// neither stable across machines nor meaningful to a person -- this machine has
|
||||
// two amdgpu cards, one discrete and one integrated, and picking a number
|
||||
// blindly measures whichever the kernel enumerated first.
|
||||
//
|
||||
// Enumerated on demand rather than polled: hardware does not appear while you
|
||||
// are looking at a settings page.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-gpus"
|
||||
|
||||
// [{ card, path, name, busy }]
|
||||
property var devices: []
|
||||
property bool scanning: false
|
||||
property string lastError: ""
|
||||
|
||||
readonly property string selectedPath: DesktopPreferences.get("gpuBusyPath")
|
||||
|
||||
readonly property var selected: root.devices.find(device => device.path === root.selectedPath) ?? null
|
||||
|
||||
// True when a GPU is stored that this machine does not have -- after moving
|
||||
// the settings file between machines, say.
|
||||
readonly property bool selectionMissing: root.devices.length > 0 && root.selected === null
|
||||
|
||||
Process {
|
||||
id: scan
|
||||
command: [root.helperPath]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.devices = Array.isArray(parsed) ? parsed : [];
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.devices = [];
|
||||
root.lastError = "The graphics devices could not be read.";
|
||||
}
|
||||
root.scanning = false;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.scanning = false;
|
||||
if (exitCode !== 0)
|
||||
root.lastError = "The graphics devices could not be read.";
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
if (scan.running)
|
||||
return;
|
||||
root.scanning = true;
|
||||
scan.running = true;
|
||||
}
|
||||
|
||||
// Only a path this machine actually reported is accepted, so a hand-edited
|
||||
// settings file cannot point the readout at an arbitrary file.
|
||||
function select(path: string): bool {
|
||||
if (!root.devices.some(device => device.path === path)) {
|
||||
root.lastError = "That graphics device is not present.";
|
||||
return false;
|
||||
}
|
||||
if (!DesktopPreferences.set("gpuBusyPath", path)) {
|
||||
root.lastError = "That graphics device could not be saved.";
|
||||
return false;
|
||||
}
|
||||
root.lastError = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
// "AMD ... [Radeon RX 7700 XT / 7800 XT] (rev c8)" is what lspci gives; the
|
||||
// bracketed marketing name is the part anyone recognises.
|
||||
function shortName(name: string): string {
|
||||
const bracketed = String(name).match(/\[([^\]]+)\]\s*(?:\(rev[^)]*\))?\s*$/);
|
||||
return bracketed ? bracketed[1] : String(name).replace(/\s*\(rev[^)]*\)\s*$/, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "weather-gpu-test"
|
||||
|
||||
function gpuStatus(): string {
|
||||
return JSON.stringify({
|
||||
count: GraphicsDevices.devices.length,
|
||||
names: GraphicsDevices.devices.map(d => GraphicsDevices.shortName(d.name)),
|
||||
paths: GraphicsDevices.devices.map(d => d.path),
|
||||
selected: GraphicsDevices.selectedPath,
|
||||
resolved: GraphicsDevices.selected !== null,
|
||||
missing: GraphicsDevices.selectionMissing,
|
||||
error: GraphicsDevices.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function selectGpu(path: string): bool { return GraphicsDevices.select(path); }
|
||||
|
||||
function geoSearch(query: string): void { Geocoding.search(query); }
|
||||
|
||||
function geoStatus(): string {
|
||||
return JSON.stringify({
|
||||
searching: Geocoding.searching,
|
||||
count: Geocoding.results.length,
|
||||
top: Geocoding.results.length > 0 ? Geocoding.results[0].label : "",
|
||||
error: Geocoding.lastError
|
||||
});
|
||||
}
|
||||
|
||||
function geoChooseTop(): bool {
|
||||
if (Geocoding.results.length === 0) return false;
|
||||
return Geocoding.choose(Geocoding.results[0]);
|
||||
}
|
||||
|
||||
function storedLocation(): string {
|
||||
return JSON.stringify({
|
||||
label: DesktopPreferences.get("weatherLocation"),
|
||||
lat: DesktopPreferences.get("weatherLatitude"),
|
||||
lon: DesktopPreferences.get("weatherLongitude")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user