From 72e9c1392a305ebd4651e8d67da08627f7e81e8a Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Fri, 11 Sep 2026 10:28:11 +0200 Subject: [PATCH] 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. --- FanController.qml | 525 +++++++++++++++++++++++++++++++++++++++ fan_controller.c | 613 ++++++++++++++++++++++++++++++++++++++++++++++ temp_sender.py | 29 +++ 3 files changed, 1167 insertions(+) create mode 100644 FanController.qml create mode 100644 fan_controller.c create mode 100644 temp_sender.py diff --git a/FanController.qml b/FanController.qml new file mode 100644 index 0000000..35ee228 --- /dev/null +++ b/FanController.qml @@ -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 } + } +} diff --git a/fan_controller.c b/fan_controller.c new file mode 100644 index 0000000..010cf6c --- /dev/null +++ b/fan_controller.c @@ -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 +#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; +} diff --git a/temp_sender.py b/temp_sender.py new file mode 100644 index 0000000..0cc46c9 --- /dev/null +++ b/temp_sender.py @@ -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)