Files
cooling_pad_fan_controller/FanController.qml
T
fegger 72e9c1392a Add RPi fan controller, laptop temp sender, and Quickshell widget
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.
2026-09-11 10:28:11 +02:00

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 }
}
}