/* * 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 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* ------------------------------------------------------------------------- * 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; }