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