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:
2026-09-11 10:28:11 +02:00
commit 72e9c1392a
3 changed files with 1167 additions and 0 deletions
+525
View File
@@ -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 }
}
}
+613
View File
@@ -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 01,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;
}
+29
View File
@@ -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)