Files
cooling_pad_fan_controller/fan_controller.c
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

614 lines
20 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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;
}