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.
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
// 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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
/*
|
||||
* fan_controller.c — Laptop cooling pad controller for Raspberry Pi Zero 2W
|
||||
*
|
||||
* Uses pigpio for hardware PWM and tach GPIO
|
||||
* Receives CPU temperature from laptop via TCP over USB gadget
|
||||
* Controls fan speed with a PID loop
|
||||
*
|
||||
* Build: gcc fan_controller.c -o fan_controller -lpigpio -lrt -lm
|
||||
* Run: sudo ./fan_controller
|
||||
*
|
||||
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
#include <math.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <signal.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
#include <errno.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <netinet/in.h>
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <sys/un.h>
|
||||
#include <sys/stat.h>
|
||||
#include <pthread.h>
|
||||
#include <pigpio.h>
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Configuration — adjust to your wiring
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* PWM — hardware PWM pins on RPi: GPIO 12, 13, 18, 19
|
||||
* GPIO 18 (physical pin 12) is the most commonly used */
|
||||
#define PWM_PIN 18 /* BCM numbering */
|
||||
#define PWM_FREQ 25000 /* 25 kHz — Intel 4-pin fan spec */
|
||||
|
||||
/* Tach — any spare GPIO, e.g. GPIO 24 (physical pin 18) */
|
||||
#define TACH_PIN 24 /* BCM numbering */
|
||||
#define TACH_POLL_MS 1000 /* measure RPM over this window */
|
||||
|
||||
/* TCP server — laptop connects to this port over USB gadget interface */
|
||||
#define TCP_PORT 9000
|
||||
#define TCP_BIND_ADDR "10.55.0.1" /* Pi's static IP on usb0 */
|
||||
#define RECV_TIMEOUT_S 5 /* seconds before assuming disconnect */
|
||||
|
||||
/* PID — tuned for Framework 13 thermal mass + Noctua A12x25 5V response */
|
||||
#define PID_SETPOINT 52.0f /* target temp °C — sits below FW13 fan
|
||||
spinup threshold (~65°C) */
|
||||
#define PID_KP 0.04f /* gentle — laptop thermals are slow */
|
||||
#define PID_KI 0.008f /* small — avoids windup on boost spikes */
|
||||
#define PID_KD 0.8f /* higher damping — A12x25 responds fast */
|
||||
#define PID_DT 2.0f /* loop interval seconds */
|
||||
|
||||
/* Fan safety — Noctua A12x25 5V specifics:
|
||||
* min reliable start: ~25% duty (~400 RPM)
|
||||
* max: 2000 RPM @ 100% */
|
||||
#define TEMP_MIN_SPIN 42.0f /* below this: fans off (FW13 idle range) */
|
||||
#define TEMP_FULL_BLAST 80.0f /* above this: always 100% */
|
||||
#define DUTY_MIN 0.25f /* A12x25 5V minimum reliable spin */
|
||||
#define DUTY_MAX 1.0f
|
||||
#define SAFE_DUTY 0.70f /* fallback if laptop disconnects */
|
||||
|
||||
/* Unix socket for widget IPC */
|
||||
#define SOCKET_PATH "/tmp/fan_controller.sock"
|
||||
|
||||
/* Logging */
|
||||
#define LOG_FILE "/var/log/fan_controller.log"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* PWM + Tach via pigpio
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* pigpio duty cycle range is 0–1,000,000 */
|
||||
#define PIGPIO_DUTY_MAX 1000000
|
||||
|
||||
static int pwm_init(void) {
|
||||
if (gpioInitialise() < 0) {
|
||||
fprintf(stderr, "pigpio initialisation failed\n"
|
||||
"Make sure pigpiod is not already running and you are root.\n");
|
||||
return -1;
|
||||
}
|
||||
/* Start at 0% duty — fan off until we have temp data */
|
||||
if (gpioHardwarePWM(PWM_PIN, PWM_FREQ, 0) != 0) {
|
||||
fprintf(stderr, "gpioHardwarePWM failed — "
|
||||
"GPIO %d may not be a hardware PWM pin.\n"
|
||||
"Valid pins: 12, 13, 18, 19 (BCM)\n", PWM_PIN);
|
||||
gpioTerminate();
|
||||
return -1;
|
||||
}
|
||||
/* Tach pin: input with internal pull-up */
|
||||
gpioSetMode(TACH_PIN, PI_INPUT);
|
||||
gpioSetPullUpDown(TACH_PIN, PI_PUD_UP);
|
||||
|
||||
printf("pigpio initialised PWM=GPIO%d @ %dHz Tach=GPIO%d\n",
|
||||
PWM_PIN, PWM_FREQ, TACH_PIN);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void pwm_set_duty(float duty) {
|
||||
if (duty < 0.0f) duty = 0.0f;
|
||||
if (duty > 1.0f) duty = 1.0f;
|
||||
gpioHardwarePWM(PWM_PIN, PWM_FREQ, (unsigned)(duty * PIGPIO_DUTY_MAX));
|
||||
}
|
||||
|
||||
static void pwm_cleanup(void) {
|
||||
gpioHardwarePWM(PWM_PIN, PWM_FREQ, 0);
|
||||
gpioTerminate();
|
||||
}
|
||||
|
||||
/* Tach: count rising edges via pigpio alert callback */
|
||||
static volatile int tach_pulses = 0;
|
||||
|
||||
static void tach_callback(int gpio, int level, uint32_t tick) {
|
||||
(void)gpio; (void)tick;
|
||||
if (level == 1) tach_pulses++;
|
||||
}
|
||||
|
||||
static int tach_init(void) {
|
||||
/* pigpio must already be initialised */
|
||||
if (gpioSetAlertFunc(TACH_PIN, tach_callback) != 0) {
|
||||
fprintf(stderr, "Warning: could not set tach alert on GPIO%d\n",
|
||||
TACH_PIN);
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int tach_read_rpm(void) {
|
||||
tach_pulses = 0;
|
||||
time_sleep(TACH_POLL_MS / 1000.0);
|
||||
int pulses = tach_pulses;
|
||||
/* 2 pulses per revolution, window = TACH_POLL_MS ms */
|
||||
float window_s = TACH_POLL_MS / 1000.0f;
|
||||
return (int)((pulses / 2.0f) * (60.0f / window_s));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* PID controller
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
float kp, ki, kd;
|
||||
float setpoint;
|
||||
float integral;
|
||||
float prev_measured;
|
||||
float out_min, out_max;
|
||||
float dt;
|
||||
int initialized;
|
||||
} PID;
|
||||
|
||||
static PID fan_pid;
|
||||
|
||||
static void pid_init(PID *p, float kp, float ki, float kd,
|
||||
float setpoint, float dt) {
|
||||
p->kp = kp;
|
||||
p->ki = ki;
|
||||
p->kd = kd;
|
||||
p->setpoint = setpoint;
|
||||
p->integral = 0.0f;
|
||||
p->prev_measured = setpoint;
|
||||
p->out_min = 0.0f;
|
||||
p->out_max = 1.0f;
|
||||
p->dt = dt;
|
||||
p->initialized = 0;
|
||||
}
|
||||
|
||||
static float pid_update(PID *p, float measured) {
|
||||
/* Seed derivative term on first call to avoid kick */
|
||||
if (!p->initialized) {
|
||||
p->prev_measured = measured;
|
||||
p->initialized = 1;
|
||||
}
|
||||
|
||||
float error = measured - p->setpoint;
|
||||
|
||||
/* Integral with anti-windup */
|
||||
p->integral += error * p->dt;
|
||||
float windup_limit = p->out_max / (p->ki > 0 ? p->ki : 1e-6f);
|
||||
if (p->integral > windup_limit) p->integral = windup_limit;
|
||||
if (p->integral < -windup_limit) p->integral = -windup_limit;
|
||||
|
||||
/* Derivative on measurement to avoid setpoint-change kick */
|
||||
float derivative = -(measured - p->prev_measured) / p->dt;
|
||||
p->prev_measured = measured;
|
||||
|
||||
float out = (p->kp * error) + (p->ki * p->integral) + (p->kd * derivative);
|
||||
|
||||
if (out > p->out_max) out = p->out_max;
|
||||
if (out < p->out_min) out = p->out_min;
|
||||
return out;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Low-pass filter
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#define LPF_ALPHA 0.2f /* 0=frozen, 1=no filter */
|
||||
|
||||
static float lpf(float prev, float raw) {
|
||||
return prev * (1.0f - LPF_ALPHA) + raw * LPF_ALPHA;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* TCP server
|
||||
*
|
||||
* The Pi listens on TCP_PORT. The laptop (temp_sender.py) connects and
|
||||
* streams newline-terminated temperature readings. If the connection drops,
|
||||
* the main loop falls back to safe mode and waits for a reconnect.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static int tcp_listen_fd = -1; /* listening socket, kept open always */
|
||||
static int tcp_client_fd = -1; /* connected client, -1 when nobody in */
|
||||
static char tcp_buf[64];
|
||||
static int tcp_buf_len = 0;
|
||||
|
||||
static int tcp_server_init(void) {
|
||||
tcp_listen_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (tcp_listen_fd < 0) { perror("socket"); return -1; }
|
||||
|
||||
/* Reuse address so restart after crash doesn't wait for TIME_WAIT */
|
||||
int yes = 1;
|
||||
setsockopt(tcp_listen_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
|
||||
|
||||
struct sockaddr_in addr = {
|
||||
.sin_family = AF_INET,
|
||||
.sin_port = htons(TCP_PORT),
|
||||
};
|
||||
inet_pton(AF_INET, TCP_BIND_ADDR, &addr.sin_addr);
|
||||
|
||||
if (bind(tcp_listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
perror("bind"); return -1;
|
||||
}
|
||||
if (listen(tcp_listen_fd, 1) < 0) {
|
||||
perror("listen"); return -1;
|
||||
}
|
||||
|
||||
/* Non-blocking so accept() in the main loop doesn't stall */
|
||||
fcntl(tcp_listen_fd, F_SETFL, O_NONBLOCK);
|
||||
|
||||
printf("TCP server listening on %s:%d\n", TCP_BIND_ADDR, TCP_PORT);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* Accept a pending connection if one is waiting.
|
||||
* Called each loop iteration when tcp_client_fd == -1.
|
||||
*/
|
||||
static void tcp_try_accept(void) {
|
||||
struct sockaddr_in client;
|
||||
socklen_t len = sizeof(client);
|
||||
int fd = accept(tcp_listen_fd, (struct sockaddr *)&client, &len);
|
||||
if (fd < 0) return; /* EAGAIN — nobody waiting, that's fine */
|
||||
|
||||
/* Enable TCP keepalive so stale connections are detected quickly */
|
||||
int yes = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &yes, sizeof(yes));
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE,
|
||||
&(int){5}, sizeof(int)); /* start probing after 5s idle */
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL,
|
||||
&(int){2}, sizeof(int)); /* probe every 2s */
|
||||
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT,
|
||||
&(int){3}, sizeof(int)); /* drop after 3 failed probes */
|
||||
|
||||
tcp_client_fd = fd;
|
||||
tcp_buf_len = 0;
|
||||
|
||||
char ip[INET_ADDRSTRLEN];
|
||||
inet_ntop(AF_INET, &client.sin_addr, ip, sizeof(ip));
|
||||
printf("Laptop connected from %s\n", ip);
|
||||
}
|
||||
|
||||
static void tcp_client_close(void) {
|
||||
if (tcp_client_fd >= 0) { close(tcp_client_fd); tcp_client_fd = -1; }
|
||||
tcp_buf_len = 0;
|
||||
printf("Laptop disconnected — entering safe mode\n");
|
||||
}
|
||||
|
||||
/*
|
||||
* Read a newline-terminated float with timeout.
|
||||
* Returns temperature, or -1.0 on timeout/disconnect.
|
||||
*/
|
||||
static float tcp_recv_temp(float timeout_s) {
|
||||
struct timeval deadline, now, tv;
|
||||
gettimeofday(&deadline, NULL);
|
||||
deadline.tv_sec += (int)timeout_s;
|
||||
deadline.tv_usec += (int)((timeout_s - (int)timeout_s) * 1e6);
|
||||
if (deadline.tv_usec >= 1000000) {
|
||||
deadline.tv_sec++;
|
||||
deadline.tv_usec -= 1000000;
|
||||
}
|
||||
|
||||
while (1) {
|
||||
gettimeofday(&now, NULL);
|
||||
long us_left = (deadline.tv_sec - now.tv_sec) * 1000000
|
||||
+ (deadline.tv_usec - now.tv_usec);
|
||||
if (us_left <= 0) return -1.0f;
|
||||
|
||||
tv.tv_sec = us_left / 1000000;
|
||||
tv.tv_usec = us_left % 1000000;
|
||||
|
||||
fd_set rfds;
|
||||
FD_ZERO(&rfds);
|
||||
FD_SET(tcp_client_fd, &rfds);
|
||||
if (select(tcp_client_fd + 1, &rfds, NULL, NULL, &tv) <= 0)
|
||||
return -1.0f;
|
||||
|
||||
char c;
|
||||
ssize_t n = read(tcp_client_fd, &c, 1);
|
||||
if (n <= 0) return -1.0f; /* connection closed or error */
|
||||
|
||||
if (c == '\n') {
|
||||
tcp_buf[tcp_buf_len] = '\0';
|
||||
float val = (float)atof(tcp_buf);
|
||||
tcp_buf_len = 0;
|
||||
return val;
|
||||
}
|
||||
if (tcp_buf_len < (int)sizeof(tcp_buf) - 1)
|
||||
tcp_buf[tcp_buf_len++] = c;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Logging
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static FILE *log_fp = NULL;
|
||||
|
||||
static void log_open(void) {
|
||||
log_fp = fopen(LOG_FILE, "a");
|
||||
if (!log_fp) fprintf(stderr, "Warning: cannot open log file %s\n", LOG_FILE);
|
||||
}
|
||||
|
||||
static void log_entry(float temp, float filtered, float duty, int rpm,
|
||||
int safe_mode) {
|
||||
time_t now = time(NULL);
|
||||
char ts[32];
|
||||
strftime(ts, sizeof(ts), "%Y-%m-%dT%H:%M:%S", localtime(&now));
|
||||
|
||||
const char *line = safe_mode ? " [SAFE MODE]" : "";
|
||||
printf("%s temp=%5.1f°C filtered=%5.1f°C duty=%3.0f%% rpm=%4d%s\n",
|
||||
ts, temp, filtered, duty * 100, rpm, line);
|
||||
fflush(stdout);
|
||||
|
||||
if (log_fp) {
|
||||
fprintf(log_fp,
|
||||
"%s,%.1f,%.1f,%.0f,%d,%d\n",
|
||||
ts, temp, filtered, duty * 100.0f, rpm, safe_mode);
|
||||
fflush(log_fp);
|
||||
}
|
||||
}
|
||||
|
||||
/* Forward declaration needed by IPC thread */
|
||||
extern volatile int running;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Unix socket IPC (for Quickshell widget)
|
||||
*
|
||||
* Protocol (newline terminated):
|
||||
* Query: "STATUS\n"
|
||||
* Reply: JSON: {"temp":52.1,"rpm":1200,"duty":45,"setpoint":52.0,"mode":"auto"}
|
||||
*
|
||||
* Command: "SET_SETPOINT 58.0\n" -> "OK\n"
|
||||
* Command: "SET_MODE auto\n" -> "OK\n"
|
||||
* Command: "SET_MODE manual\n" -> "OK\n"
|
||||
* Command: "SET_DUTY 75\n" -> "OK\n" (manual mode only, 0-100)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/* Shared state accessed by IPC thread */
|
||||
typedef struct {
|
||||
float temp;
|
||||
float filtered_temp;
|
||||
float duty;
|
||||
int rpm;
|
||||
int safe_mode;
|
||||
/* Writable by widget */
|
||||
float setpoint;
|
||||
int manual_mode;
|
||||
float manual_duty;
|
||||
} FanState;
|
||||
|
||||
static FanState state = {0};
|
||||
static pthread_mutex_t state_mutex = PTHREAD_MUTEX_INITIALIZER;
|
||||
|
||||
static int ipc_fd = -1;
|
||||
|
||||
static void ipc_handle_client(int cfd) {
|
||||
char buf[128] = {0};
|
||||
ssize_t n = recv(cfd, buf, sizeof(buf) - 1, 0);
|
||||
if (n <= 0) { close(cfd); return; }
|
||||
|
||||
/* Strip trailing newline/whitespace */
|
||||
for (int i = n - 1; i >= 0 && (buf[i] == '\n' || buf[i] == '\r'
|
||||
|| buf[i] == ' '); i--)
|
||||
buf[i] = '\0';
|
||||
|
||||
char reply[256];
|
||||
|
||||
pthread_mutex_lock(&state_mutex);
|
||||
|
||||
if (strcmp(buf, "STATUS") == 0) {
|
||||
snprintf(reply, sizeof(reply),
|
||||
"{\"temp\":%.1f,\"rpm\":%d,\"duty\":%.0f,"
|
||||
"\"setpoint\":%.1f,\"mode\":\"%s\",\"safe\":%d}\n",
|
||||
state.filtered_temp, state.rpm, state.duty * 100.0f,
|
||||
state.setpoint,
|
||||
state.manual_mode ? "manual" : "auto",
|
||||
state.safe_mode);
|
||||
|
||||
} else if (strncmp(buf, "SET_SETPOINT ", 13) == 0) {
|
||||
float sp = (float)atof(buf + 13);
|
||||
if (sp >= 35.0f && sp <= 85.0f) {
|
||||
state.setpoint = sp;
|
||||
fan_pid.setpoint = sp;
|
||||
snprintf(reply, sizeof(reply), "OK\n");
|
||||
} else {
|
||||
snprintf(reply, sizeof(reply), "ERR setpoint out of range\n");
|
||||
}
|
||||
|
||||
} else if (strncmp(buf, "SET_MODE ", 9) == 0) {
|
||||
if (strcmp(buf + 9, "manual") == 0) state.manual_mode = 1;
|
||||
else if (strcmp(buf + 9, "auto") == 0) state.manual_mode = 0;
|
||||
snprintf(reply, sizeof(reply), "OK\n");
|
||||
|
||||
} else if (strncmp(buf, "SET_DUTY ", 9) == 0) {
|
||||
float d = (float)atof(buf + 9) / 100.0f;
|
||||
if (d >= 0.0f && d <= 1.0f) {
|
||||
state.manual_duty = d;
|
||||
snprintf(reply, sizeof(reply), "OK\n");
|
||||
} else {
|
||||
snprintf(reply, sizeof(reply), "ERR duty out of range\n");
|
||||
}
|
||||
|
||||
} else {
|
||||
snprintf(reply, sizeof(reply), "ERR unknown command\n");
|
||||
}
|
||||
|
||||
pthread_mutex_unlock(&state_mutex);
|
||||
|
||||
send(cfd, reply, strlen(reply), 0);
|
||||
close(cfd);
|
||||
}
|
||||
|
||||
static void *ipc_thread(void *arg) {
|
||||
(void)arg;
|
||||
while (running) {
|
||||
struct timeval tv = { .tv_sec = 1, .tv_usec = 0 };
|
||||
fd_set rfds;
|
||||
FD_ZERO(&rfds);
|
||||
FD_SET(ipc_fd, &rfds);
|
||||
if (select(ipc_fd + 1, &rfds, NULL, NULL, &tv) > 0) {
|
||||
int cfd = accept(ipc_fd, NULL, NULL);
|
||||
if (cfd >= 0) ipc_handle_client(cfd);
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static int ipc_init(void) {
|
||||
unlink(SOCKET_PATH); /* remove stale socket */
|
||||
|
||||
ipc_fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
if (ipc_fd < 0) { perror("ipc socket"); return -1; }
|
||||
|
||||
struct sockaddr_un addr = { .sun_family = AF_UNIX };
|
||||
strncpy(addr.sun_path, SOCKET_PATH, sizeof(addr.sun_path) - 1);
|
||||
|
||||
if (bind(ipc_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
|
||||
perror("ipc bind"); return -1;
|
||||
}
|
||||
if (listen(ipc_fd, 4) < 0) {
|
||||
perror("ipc listen"); return -1;
|
||||
}
|
||||
|
||||
/* Make socket readable by non-root (widget runs as user) */
|
||||
chmod(SOCKET_PATH, 0666);
|
||||
|
||||
pthread_t tid;
|
||||
pthread_create(&tid, NULL, ipc_thread, NULL);
|
||||
pthread_detach(tid);
|
||||
|
||||
printf("IPC socket: %s\n", SOCKET_PATH);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void ipc_cleanup(void) {
|
||||
if (ipc_fd >= 0) { close(ipc_fd); ipc_fd = -1; }
|
||||
unlink(SOCKET_PATH);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Signal handling
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
volatile int running = 1;
|
||||
|
||||
static void handle_signal(int sig) {
|
||||
(void)sig;
|
||||
running = 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Main
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
int main(void) {
|
||||
/* Signals */
|
||||
signal(SIGINT, handle_signal);
|
||||
signal(SIGTERM, handle_signal);
|
||||
|
||||
printf("=== RPi Zero 2W Fan Controller (USB gadget TCP) ===\n");
|
||||
|
||||
if (pwm_init() < 0) return EXIT_FAILURE; /* also calls gpioInitialise */
|
||||
tach_init(); /* non-fatal if it fails */
|
||||
if (tcp_server_init() < 0) return EXIT_FAILURE;
|
||||
if (ipc_init() < 0) return EXIT_FAILURE;
|
||||
|
||||
pid_init(&fan_pid, PID_KP, PID_KI, PID_KD, PID_SETPOINT, PID_DT);
|
||||
state.setpoint = PID_SETPOINT;
|
||||
state.manual_mode = 0;
|
||||
state.manual_duty = DUTY_MIN;
|
||||
log_open();
|
||||
|
||||
float filtered_temp = PID_SETPOINT;
|
||||
int safe_mode = 0;
|
||||
|
||||
printf("PID setpoint=%.1f°C kp=%.3f ki=%.3f kd=%.3f dt=%.1fs\n",
|
||||
PID_SETPOINT, PID_KP, PID_KI, PID_KD, PID_DT);
|
||||
printf("%-24s %-10s %-12s %-8s %-6s %s\n",
|
||||
"Timestamp", "Temp(°C)", "Filtered(°C)", "Duty(%)", "RPM", "Mode");
|
||||
|
||||
while (running) {
|
||||
/* --- Accept new connection if we don't have one --- */
|
||||
if (tcp_client_fd < 0) tcp_try_accept();
|
||||
|
||||
/* --- Receive temperature --- */
|
||||
float raw_temp = (tcp_client_fd >= 0)
|
||||
? tcp_recv_temp(RECV_TIMEOUT_S) : -1.0f;
|
||||
|
||||
if (raw_temp < 0.0f) {
|
||||
if (tcp_client_fd >= 0) tcp_client_close();
|
||||
safe_mode = 1;
|
||||
raw_temp = filtered_temp; /* log last known */
|
||||
} else {
|
||||
safe_mode = 0;
|
||||
filtered_temp = lpf(filtered_temp, raw_temp);
|
||||
}
|
||||
|
||||
/* --- Compute duty cycle --- */
|
||||
float duty;
|
||||
|
||||
pthread_mutex_lock(&state_mutex);
|
||||
int manual_mode = state.manual_mode;
|
||||
float manual_duty = state.manual_duty;
|
||||
/* Sync PID setpoint in case widget changed it */
|
||||
fan_pid.setpoint = state.setpoint;
|
||||
pthread_mutex_unlock(&state_mutex);
|
||||
|
||||
if (safe_mode) {
|
||||
duty = SAFE_DUTY;
|
||||
} else if (manual_mode) {
|
||||
duty = manual_duty;
|
||||
} else if (filtered_temp >= TEMP_FULL_BLAST) {
|
||||
duty = DUTY_MAX;
|
||||
} else if (filtered_temp < TEMP_MIN_SPIN) {
|
||||
duty = 0.0f;
|
||||
} else {
|
||||
duty = pid_update(&fan_pid, filtered_temp);
|
||||
if (duty > 0.0f && duty < DUTY_MIN)
|
||||
duty = DUTY_MIN;
|
||||
}
|
||||
|
||||
/* --- Apply PWM --- */
|
||||
pwm_set_duty(duty);
|
||||
|
||||
/* --- Read RPM --- */
|
||||
int rpm = tach_read_rpm();
|
||||
|
||||
/* --- Update shared state for IPC --- */
|
||||
pthread_mutex_lock(&state_mutex);
|
||||
state.temp = raw_temp;
|
||||
state.filtered_temp = filtered_temp;
|
||||
state.duty = duty;
|
||||
state.rpm = rpm;
|
||||
state.safe_mode = safe_mode;
|
||||
pthread_mutex_unlock(&state_mutex);
|
||||
|
||||
/* --- Log --- */
|
||||
log_entry(raw_temp, filtered_temp, duty, rpm, safe_mode);
|
||||
|
||||
/* --- Sleep remainder of dt (tach read already consumed ~1s) --- */
|
||||
float sleep_s = PID_DT - (TACH_POLL_MS / 1000.0f);
|
||||
if (sleep_s > 0.0f) usleep((useconds_t)(sleep_s * 1e6));
|
||||
}
|
||||
|
||||
/* --- Cleanup --- */
|
||||
printf("\nShutting down — spinning fans up briefly then off...\n");
|
||||
pwm_set_duty(1.0f);
|
||||
sleep(2);
|
||||
pwm_cleanup(); /* also calls gpioTerminate */
|
||||
tcp_client_close();
|
||||
if (tcp_listen_fd >= 0) close(tcp_listen_fd);
|
||||
ipc_cleanup();
|
||||
if (log_fp) fclose(log_fp);
|
||||
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import socket, time
|
||||
import subprocess
|
||||
|
||||
RPI_IP = "10.55.0.1"
|
||||
RPI_PORT = 9000
|
||||
INTERVAL = 2.0
|
||||
|
||||
def get_temp():
|
||||
out = subprocess.check_output(["sensors", "-u"]).decode()
|
||||
# AMD Ryzen — Tctl is the control temp the firmware uses
|
||||
for line in out.splitlines():
|
||||
if "tctl" in line.lower() and "input" in line.lower():
|
||||
return float(line.split(":")[1].strip())
|
||||
# fallback to thermal_zone0
|
||||
with open("/sys/class/thermal/thermal_zone0/temp") as f:
|
||||
return int(f.read().strip()) / 1000.0
|
||||
|
||||
# Auto-reconnect loop
|
||||
while True:
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.connect((RPI_IP, RPI_PORT))
|
||||
print(f"Connected to {RPI_IP}:{RPI_PORT}")
|
||||
while True:
|
||||
s.sendall(f"{get_temp():.1f}\n".encode())
|
||||
time.sleep(INTERVAL)
|
||||
except (OSError, ConnectionResetError) as e:
|
||||
print(f"Connection lost: {e} — retrying in 5s")
|
||||
time.sleep(5)
|
||||
Reference in New Issue
Block a user