72e9c1392a
Initial implementation of a USB-gadget-linked cooling controller: - fan_controller.c: PWM PID control, tach reading, TCP temp receiver, Unix socket IPC, safe-mode fallback, CSV logging via pigpio. - temp_sender.py: laptop-side lm-sensors/thermal_zone temp sender with auto-reconnect. - FanController.qml: Quickshell floating widget for monitoring and setpoint/manual duty control over the Unix socket.
526 lines
18 KiB
QML
526 lines
18 KiB
QML
// FanController.qml — Quickshell floating widget for fan controller
|
|
//
|
|
// Communicates with fan_controller via Unix socket at /tmp/fan_controller.sock
|
|
// Protocol: send "STATUS\n" -> receive JSON
|
|
// send "SET_SETPOINT 55.0\n" -> "OK\n"
|
|
// send "SET_MODE auto\n" / "SET_MODE manual\n" -> "OK\n"
|
|
// send "SET_DUTY 75\n" -> "OK\n"
|
|
//
|
|
// Place in your Quickshell config directory and import from your shell.qml
|
|
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
|
|
ShellWindow {
|
|
id: root
|
|
visible: true
|
|
color: "transparent"
|
|
|
|
// Position — adjust to your screen/bar layout
|
|
anchors {
|
|
right: true
|
|
top: true
|
|
margins: 16
|
|
}
|
|
|
|
width: 320
|
|
height: contentCol.implicitHeight + 32
|
|
|
|
// ── Colours ──────────────────────────────────────────────────────────────
|
|
readonly property color bg: "#ee1e1e2e" // Catppuccin Mocha base
|
|
readonly property color surface: "#ee313244" // surface0
|
|
readonly property color overlay: "#ee45475a" // overlay0
|
|
readonly property color textPrimary: "#cdd6f4" // text
|
|
readonly property color textMuted: "#6c7086" // overlay1
|
|
readonly property color accent: "#89b4fa" // blue
|
|
readonly property color green: "#a6e3a1"
|
|
readonly property color yellow: "#f9e2af"
|
|
readonly property color red: "#f38ba8"
|
|
readonly property color manualColor: "#fab387" // peach
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────
|
|
property real temp: 0
|
|
property int rpm: 0
|
|
property real duty: 0
|
|
property real setpoint: 52
|
|
property bool manualMode: false
|
|
property real manualDuty: 25
|
|
property bool safeMode: false
|
|
property bool connected: false
|
|
|
|
// History for graph — 5 min at 2s interval = 150 samples
|
|
property var tempHistory: []
|
|
property var rpmHistory: []
|
|
property int maxHistory: 150
|
|
|
|
// ── Colour helpers ────────────────────────────────────────────────────────
|
|
function tempColor(t) {
|
|
if (t < 50) return green
|
|
if (t < 65) return yellow
|
|
return red
|
|
}
|
|
|
|
function rpmColor(r) {
|
|
if (r < 600) return textMuted
|
|
if (r < 1400) return accent
|
|
return red
|
|
}
|
|
|
|
// ── IPC socket ───────────────────────────────────────────────────────────
|
|
function sendCommand(cmd) {
|
|
ipcSocket.write(cmd + "\n")
|
|
}
|
|
|
|
function pollStatus() {
|
|
sendCommand("STATUS")
|
|
}
|
|
|
|
// Parse JSON status reply
|
|
function handleReply(raw) {
|
|
try {
|
|
var d = JSON.parse(raw.trim())
|
|
temp = d.temp ?? temp
|
|
rpm = d.rpm ?? rpm
|
|
duty = d.duty ?? duty
|
|
setpoint = d.setpoint ?? setpoint
|
|
manualMode = (d.mode === "manual")
|
|
safeMode = d.safe === 1
|
|
connected = true
|
|
|
|
// Append to history
|
|
var th = tempHistory.slice()
|
|
var rh = rpmHistory.slice()
|
|
th.push(temp)
|
|
rh.push(rpm)
|
|
if (th.length > maxHistory) th.shift()
|
|
if (rh.length > maxHistory) rh.shift()
|
|
tempHistory = th
|
|
rpmHistory = rh
|
|
graph.requestPaint()
|
|
|
|
} catch(e) {
|
|
// Non-JSON reply (OK/ERR) — ignore
|
|
}
|
|
}
|
|
|
|
IpcSocket {
|
|
id: ipcSocket
|
|
path: "/tmp/fan_controller.sock"
|
|
|
|
onConnected: {
|
|
root.connected = true
|
|
pollStatus()
|
|
}
|
|
onDisconnected: {
|
|
root.connected = false
|
|
}
|
|
onDataReceived: (data) => {
|
|
root.handleReply(data)
|
|
}
|
|
}
|
|
|
|
// Poll every 2 seconds
|
|
Timer {
|
|
interval: 2000
|
|
running: true
|
|
repeat: true
|
|
onTriggered: {
|
|
if (!ipcSocket.connected) {
|
|
ipcSocket.connect()
|
|
} else {
|
|
root.pollStatus()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Background ───────────────────────────────────────────────────────────
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: root.bg
|
|
radius: 12
|
|
border.color: root.overlay
|
|
border.width: 1
|
|
|
|
// Drag to reposition
|
|
DragHandler {}
|
|
}
|
|
|
|
// ── Content ───────────────────────────────────────────────────────────────
|
|
ColumnLayout {
|
|
id: contentCol
|
|
anchors {
|
|
left: parent.left
|
|
right: parent.right
|
|
top: parent.top
|
|
margins: 16
|
|
}
|
|
spacing: 12
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
|
|
Text {
|
|
text: " Fan Controller"
|
|
color: root.textPrimary
|
|
font.pixelSize: 14
|
|
font.weight: Font.Medium
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
|
|
// Connection / safe mode indicator
|
|
Rectangle {
|
|
width: 8; height: 8; radius: 4
|
|
color: !root.connected ? root.red
|
|
: root.safeMode ? root.yellow
|
|
: root.green
|
|
ToolTip.visible: hovered
|
|
ToolTip.text: !root.connected ? "Disconnected"
|
|
: root.safeMode ? "Safe mode"
|
|
: "Connected"
|
|
HoverHandler {}
|
|
}
|
|
}
|
|
|
|
// Divider
|
|
Rectangle { Layout.fillWidth: true; height: 1; color: root.overlay }
|
|
|
|
// ── Stats row ─────────────────────────────────────────────────────────
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 0
|
|
|
|
// Temperature
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 2
|
|
Text {
|
|
text: root.connected ? root.temp.toFixed(1) + "°C" : "--"
|
|
color: root.connected ? root.tempColor(root.temp) : root.textMuted
|
|
font.pixelSize: 26
|
|
font.weight: Font.Bold
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
Text {
|
|
text: "Temperature"
|
|
color: root.textMuted
|
|
font.pixelSize: 11
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
}
|
|
|
|
// Divider
|
|
Rectangle { width: 1; height: 40; color: root.overlay }
|
|
|
|
// RPM
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 2
|
|
Text {
|
|
text: root.connected ? root.rpm + " RPM" : "--"
|
|
color: root.connected ? root.rpmColor(root.rpm) : root.textMuted
|
|
font.pixelSize: 26
|
|
font.weight: Font.Bold
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
Text {
|
|
text: "Fan Speed"
|
|
color: root.textMuted
|
|
font.pixelSize: 11
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
}
|
|
|
|
// Divider
|
|
Rectangle { width: 1; height: 40; color: root.overlay }
|
|
|
|
// Duty
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 2
|
|
Text {
|
|
text: root.connected ? root.duty.toFixed(0) + "%" : "--"
|
|
color: root.connected ? root.accent : root.textMuted
|
|
font.pixelSize: 26
|
|
font.weight: Font.Bold
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
Text {
|
|
text: "Duty Cycle"
|
|
color: root.textMuted
|
|
font.pixelSize: 11
|
|
Layout.alignment: Qt.AlignHCenter
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Graph ─────────────────────────────────────────────────────────────
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 80
|
|
color: root.surface
|
|
radius: 6
|
|
clip: true
|
|
|
|
// Y-axis labels
|
|
Text {
|
|
anchors { right: parent.right; top: parent.top; margins: 4 }
|
|
text: "85°C"
|
|
color: root.textMuted
|
|
font.pixelSize: 9
|
|
}
|
|
Text {
|
|
anchors { right: parent.right; bottom: parent.bottom; margins: 4 }
|
|
text: "35°C"
|
|
color: root.textMuted
|
|
font.pixelSize: 9
|
|
}
|
|
|
|
// Setpoint line
|
|
Rectangle {
|
|
x: 0
|
|
width: parent.width
|
|
height: 1
|
|
color: root.accent
|
|
opacity: 0.4
|
|
y: {
|
|
var pct = 1.0 - (root.setpoint - 35) / 50.0
|
|
return Math.max(0, Math.min(parent.height - 1, pct * parent.height))
|
|
}
|
|
}
|
|
|
|
Canvas {
|
|
id: graph
|
|
anchors.fill: parent
|
|
|
|
onPaint: {
|
|
var ctx = getContext("2d")
|
|
ctx.clearRect(0, 0, width, height)
|
|
|
|
var hist = root.tempHistory
|
|
if (hist.length < 2) return
|
|
|
|
var minT = 35, maxT = 85
|
|
var w = width, h = height
|
|
|
|
// Temp line
|
|
ctx.beginPath()
|
|
ctx.strokeStyle = Qt.rgba(0.89, 0.54, 0.47, 0.9) // red-ish
|
|
ctx.lineWidth = 1.5
|
|
ctx.lineJoin = "round"
|
|
|
|
for (var i = 0; i < hist.length; i++) {
|
|
var x = (i / (root.maxHistory - 1)) * w
|
|
var y = h - ((hist[i] - minT) / (maxT - minT)) * h
|
|
y = Math.max(0, Math.min(h, y))
|
|
if (i === 0) ctx.moveTo(x, y)
|
|
else ctx.lineTo(x, y)
|
|
}
|
|
ctx.stroke()
|
|
|
|
// Fill under line
|
|
ctx.lineTo((hist.length - 1) / (root.maxHistory - 1) * w, h)
|
|
ctx.lineTo(0, h)
|
|
ctx.closePath()
|
|
ctx.fillStyle = Qt.rgba(0.89, 0.54, 0.47, 0.15)
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Mode toggle ───────────────────────────────────────────────────────
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
|
|
Text {
|
|
text: "Mode"
|
|
color: root.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
|
|
Rectangle {
|
|
width: 130; height: 26
|
|
radius: 13
|
|
color: root.surface
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
radius: 13
|
|
color: !root.manualMode ? root.accent : "transparent"
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "Auto"
|
|
color: !root.manualMode ? root.bg : root.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: Font.Medium
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.sendCommand("SET_MODE auto")
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
radius: 13
|
|
color: root.manualMode ? root.manualColor : "transparent"
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "Manual"
|
|
color: root.manualMode ? root.bg : root.textMuted
|
|
font.pixelSize: 11
|
|
font.weight: Font.Medium
|
|
}
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.sendCommand("SET_MODE manual")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Setpoint slider (auto mode) ───────────────────────────────────────
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
visible: !root.manualMode
|
|
opacity: visible ? 1 : 0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Text {
|
|
text: "Setpoint"
|
|
color: root.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Text {
|
|
text: root.setpoint.toFixed(1) + "°C"
|
|
color: root.accent
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
}
|
|
}
|
|
|
|
Slider {
|
|
id: setpointSlider
|
|
Layout.fillWidth: true
|
|
from: 40; to: 80; stepSize: 0.5
|
|
value: root.setpoint
|
|
|
|
onPressedChanged: {
|
|
if (!pressed)
|
|
root.sendCommand("SET_SETPOINT " + value.toFixed(1))
|
|
}
|
|
|
|
background: Rectangle {
|
|
x: setpointSlider.leftPadding
|
|
y: setpointSlider.topPadding + setpointSlider.availableHeight / 2 - height / 2
|
|
width: setpointSlider.availableWidth
|
|
height: 4
|
|
radius: 2
|
|
color: root.overlay
|
|
|
|
Rectangle {
|
|
width: setpointSlider.visualPosition * parent.width
|
|
height: parent.height
|
|
radius: 2
|
|
color: root.accent
|
|
}
|
|
}
|
|
|
|
handle: Rectangle {
|
|
x: setpointSlider.leftPadding + setpointSlider.visualPosition
|
|
* (setpointSlider.availableWidth - width)
|
|
y: setpointSlider.topPadding + setpointSlider.availableHeight / 2 - height / 2
|
|
width: 14; height: 14; radius: 7
|
|
color: root.accent
|
|
border.color: root.bg
|
|
border.width: 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Manual duty slider ────────────────────────────────────────────────
|
|
ColumnLayout {
|
|
Layout.fillWidth: true
|
|
spacing: 4
|
|
visible: root.manualMode
|
|
opacity: visible ? 1 : 0
|
|
Behavior on opacity { NumberAnimation { duration: 150 } }
|
|
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Text {
|
|
text: "Fan Duty"
|
|
color: root.textMuted
|
|
font.pixelSize: 12
|
|
}
|
|
Item { Layout.fillWidth: true }
|
|
Text {
|
|
text: root.manualDuty.toFixed(0) + "%"
|
|
color: root.manualColor
|
|
font.pixelSize: 12
|
|
font.weight: Font.Medium
|
|
}
|
|
}
|
|
|
|
Slider {
|
|
id: dutySlider
|
|
Layout.fillWidth: true
|
|
from: 0; to: 100; stepSize: 1
|
|
value: root.manualDuty
|
|
|
|
onValueChanged: root.manualDuty = value
|
|
onPressedChanged: {
|
|
if (!pressed)
|
|
root.sendCommand("SET_DUTY " + value.toFixed(0))
|
|
}
|
|
|
|
background: Rectangle {
|
|
x: dutySlider.leftPadding
|
|
y: dutySlider.topPadding + dutySlider.availableHeight / 2 - height / 2
|
|
width: dutySlider.availableWidth
|
|
height: 4
|
|
radius: 2
|
|
color: root.overlay
|
|
|
|
Rectangle {
|
|
width: dutySlider.visualPosition * parent.width
|
|
height: parent.height
|
|
radius: 2
|
|
color: root.manualColor
|
|
}
|
|
}
|
|
|
|
handle: Rectangle {
|
|
x: dutySlider.leftPadding + dutySlider.visualPosition
|
|
* (dutySlider.availableWidth - width)
|
|
y: dutySlider.topPadding + dutySlider.availableHeight / 2 - height / 2
|
|
width: 14; height: 14; radius: 7
|
|
color: root.manualColor
|
|
border.color: root.bg
|
|
border.width: 2
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bottom padding
|
|
Item { height: 4 }
|
|
}
|
|
}
|