Compare commits

...

24 Commits

Author SHA1 Message Date
42e49b6c06 added lighting fading in and out, when connection is lost/reestablished 2026-01-20 14:44:28 +01:00
153d134f88 fixed the 'program to big' error by flashing the ESPUI website files to SPIFFS 2026-01-20 12:49:24 +01:00
d3586e697b added fastLED and LED output, doesn't compile yet, compiled binary is to big 2026-01-20 11:48:01 +01:00
9312ceb22b added lighting settings to UI and prefs 2026-01-20 11:24:38 +01:00
0197266418 first refactor in to multiple files 2026-01-20 10:32:30 +01:00
48059da373 added connection status to the webUI 2026-01-20 09:50:22 +01:00
536ecaa7d9 added improved the 'animation' transition between New Connection and running connection 2026-01-20 08:35:36 +01:00
60f12e22e4 added fade in if a new connection is established 2026-01-20 08:14:52 +01:00
31dcc5f502 removed some unused code from previous UI experiments 2026-01-20 05:46:59 +01:00
49daa09aa6 added first iteration of a calibration menu to the webui 2026-01-19 17:53:01 +01:00
e634563cdd added webui to the ESP32, for now contains only UDP port settings and some placeholders 2026-01-19 13:27:01 +01:00
3fc15c85d3 added reload config, and show config buttons to the context menu 2026-01-19 03:39:12 +01:00
81d6bced05 made the serial debug output a bit nicer 2026-01-19 03:26:30 +01:00
ad66265260 single-pass update on the hardware sensors, should reduce CPU usage a bit 2026-01-19 03:15:03 +01:00
9fa0e4466c added periodic recreation of the LHM objects, to prevent memory leaks 2026-01-19 02:55:48 +01:00
f21269daa5 explicitly disable branches of the hardware tree that are not needed, hopefully reduce memory footprint a bit 2026-01-19 02:45:24 +01:00
b8d5e25352 added exception handling, fixes errors when waking up, or missing network 2026-01-18 14:48:32 +01:00
c1d7ba4b3d fixed the broken fade out when the watchdog kicks in 2026-01-18 06:06:57 +01:00
a9957bc695 switched back to ESP32 and UDP, but without OSC, because serial under windows is a bitch 2026-01-18 05:41:51 +01:00
2311647885 initial version with serial link 2026-01-18 02:25:38 +01:00
d432db9985 added ping command for device identification 2026-01-17 02:59:14 +01:00
73c7dfb8e5 initial serial implementation, switched to RP2040 2026-01-17 02:11:49 +01:00
aac8f3c820 excluded the release dir 2026-01-16 09:43:10 +01:00
12edafd580 added build script 2026-01-16 09:39:25 +01:00
16 changed files with 1626 additions and 741 deletions

View File

@@ -0,0 +1,54 @@
COMMAND BEHAVIOR SUMMARY
========================
PING
----
Format:
PING
Response:
Analog_System_Monitor_<version>
Purpose:
- Confirms the device is alive
- Allows PC-side auto-discovery
- Returns firmware version
SETALL
------
Format:
SETALL: v0,v1,v2,v3,v4,v5,v6,v7
Rules:
- Exactly 8 comma-separated values
- Each value must be numeric
- Each value must be between 0 and 100
- No extra values allowed
- No missing values allowed
- No trailing commas or extra characters
Success Response:
OK
Error Response:
ERROR
Purpose:
- Updates all 8 channels in one atomic operation
- Applies calibration to each channel
- Resets watchdog timer
UNKNOWN / MALFORMED COMMANDS
----------------------------
Format:
Anything not matching PING or a valid SETALL command
Response:
ERROR
Purpose:
- Keeps protocol strict and predictable
- Prevents accidental meter movement
- Simplifies debugging

View File

@@ -0,0 +1,105 @@
#pragma once
#include <Arduino.h>
// -------------------------------
// Firmware version
// -------------------------------
static const char* FIRMWARE_VERSION = "V2.4_UDP_LEDC_WM_SLEW";
// -------------------------------
// UDP
// -------------------------------
static const int listenPort = 12345; // default / legacy constant
static const unsigned long watchdogTimeout = 5000; // 5 seconds
// -------------------------------
// PWM setup (LEDC, ESP32 Core 3.x)
// -------------------------------
static const uint8_t NUM_CHANNELS = 8;
static const uint8_t pwmPins[NUM_CHANNELS] = {
26, // D0
22, // D1
21, // D2
17, // D3
16, // D4
5, // D5
18, // D6
19 // D7
};
static const uint32_t pwmFrequency = 25000; // 25 kHz
static const uint8_t pwmResolution = 10; // 10-bit resolution (01023)
// -------------------------------
// Channel labels for debugging
// -------------------------------
static const char* const channelLabels[NUM_CHANNELS] = {
"CPU Load",
"CPU Temp",
"RAM Usage",
"GPU Load",
"GPU Temp",
"VRAM Usage",
"Reserved 6",
"Reserved 7"
};
static const char* const channelUnits[NUM_CHANNELS] = {
"%", // CPU Load
"°C", // CPU Temp
"%", // RAM Usage
"%", // GPU Load
"°C", // GPU Temp
"%", // VRAM Usage
"", // Reserved
"" // Reserved
};
// -------------------------------
// Channel labels ESPUI
// -------------------------------
static const char* const channelDropdownLabels[NUM_CHANNELS] = {
"CH0 (CPU Load)",
"CH1 (CPU Temp)",
"CH2 (RAM Usage)",
"CH3 (GPU Load)",
"CH4 (GPU Temp)",
"CH5 (VRAM Usage)",
"CH6 (Reserved 6)",
"CH7 (Reserved 7)"
};
// -------------------------------
// Calibration logical points
// -------------------------------
static const unsigned long slewDuration = 1000; // 1 second smooth transition
// -------------------------------
// Animation tuning
// -------------------------------
static const float FADE_IN_FACTOR = 0.998f; // boot-up 0 → 100%
static const float FADE_OUT_FACTOR = 0.999f; // watchdog 100% → 0
static const unsigned long FADE_INTERVAL = 1; // ms between fade steps
// Lighting fade durations (milliseconds)
#define LIGHTING_FADE_IN_DURATION 1250
#define LIGHTING_FADE_OUT_DURATION 4250
// Clamping brightness at the low end
const uint8_t BRIGHTNESS_MIN_VISIBLE = 33;
// -------------------------------
// Lighting (FastLED)
// -------------------------------
#define LED_PIN 23
#define NUM_LEDS 20
// -------------------------------
// Connection state machine
// -------------------------------
enum ConnectionState {
STATE_DISCONNECTED, // fade to zero
STATE_CONNECTING, // fade in 0 → 100%
STATE_WAIT_FOR_FIRST_PACKET, // hold at 100%, wait for first UDP
STATE_CONNECTED // normal UDP-driven slew
};

View File

@@ -0,0 +1,438 @@
#include <Arduino.h>
#include "Core.h"
#include <FastLED.h>
WiFiUDP udp;
Preferences prefs;
int udpPort = listenPort;
float currentDuty[NUM_CHANNELS] = {0.0f};
float targetDuty[NUM_CHANNELS] = {0.0f};
float slewStartDuty[NUM_CHANNELS] = {0.0f};
unsigned long slewStartTime = 0;
unsigned long lastPacketTime = 0;
unsigned long lastFadeTime = 0;
float logicalPoints[5] = {0, 25, 50, 75, 100};
float calibratedPoints[NUM_CHANNELS][5] = {
{0.0f, 25.0f, 50.0f, 75.0f, 99.0f},
{0.0f, 24.0f, 49.0f, 74.0f, 98.0f},
{0.0f, 26.0f, 51.0f, 76.0f, 99.0f},
{0.0f, 25.0f, 50.0f, 75.0f, 97.0f},
{0.0f, 25.0f, 50.0f, 75.0f, 99.0f},
{0.0f, 24.0f, 50.0f, 74.0f, 98.0f},
{0.0f, 25.0f, 49.0f, 75.0f, 97.0f},
{0.0f, 26.0f, 50.0f, 76.0f, 99.0f}
};
CRGB leds[NUM_LEDS];
uint8_t lightingHue = 0;
uint8_t lightingSaturation = 255;
uint8_t lightingBrightness = 255;
uint8_t lightingBrightnessSaved = 255;
// Lighting fade state
bool lightingFading = false;
uint8_t lightingFadeStart = 0;
uint8_t lightingFadeEnd = 0;
unsigned long lightingFadeStartTime = 0;
unsigned long lightingFadeDuration = 0;
bool overrideActive[NUM_CHANNELS] = {false};
ConnectionState connectionState = STATE_DISCONNECTED;
// UI IDs (defined here, used also by UI.cpp)
uint16_t connectionStatusLabel;
uint16_t calChannelDropdown;
uint16_t calInputs[5];
uint16_t calTestValueInput;
uint16_t calSaveButton;
uint16_t calOverrideSwitch;
uint8_t selectedCalChannel = 0;
// -------------------------------
// Calibration interpolation
// -------------------------------
float applyCalibration(uint8_t ch, float logicalDuty) {
if (logicalDuty <= 0.0f) return calibratedPoints[ch][0];
if (logicalDuty >= 100.0f) return calibratedPoints[ch][4];
for (int i = 0; i < 4; i++) {
if (logicalDuty >= logicalPoints[i] && logicalDuty <= logicalPoints[i+1]) {
float x0 = logicalPoints[i];
float x1 = logicalPoints[i+1];
float y0 = calibratedPoints[ch][i];
float y1 = calibratedPoints[ch][i+1];
float t = (logicalDuty - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}
}
return calibratedPoints[ch][4];
}
void applyLighting() {
// Convert HSV (0255 each) to RGB
CHSV hsv(lightingHue, lightingSaturation, lightingBrightness);
for (int i = 0; i < NUM_LEDS; i++) {
leds[i] = hsv;
}
FastLED.show();
}
void startLightingFade(uint8_t from, uint8_t to, unsigned long duration) {
lightingFadeStart = from;
lightingFadeEnd = to;
lightingFadeDuration = duration;
lightingFadeStartTime = millis();
lightingFading = true;
}
void updateConnectionStatusUI(ConnectionState state) {
const char* text = "Unknown";
switch (state) {
case STATE_DISCONNECTED:
text = "Disconnected";
break;
case STATE_CONNECTING:
text = "Connecting...";
break;
case STATE_WAIT_FOR_FIRST_PACKET:
text = "Waiting for first UDP packet";
break;
case STATE_CONNECTED:
text = "Connected";
break;
}
ESPUI.updateControlValue(connectionStatusLabel, text);
}
// -------------------------------
// Core init (called from setup)
// -------------------------------
void coreInit() {
// LEDC PWM init
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
bool ok = ledcAttach(pwmPins[ch], pwmFrequency, pwmResolution);
if (!ok) {
Serial.print("LEDC attach failed on pin ");
Serial.println(pwmPins[ch]);
}
ledcWrite(pwmPins[ch], 0);
}
// Preferences: load UDP port (default = listenPort)
prefs.begin("analogmon", false);
udpPort = prefs.getInt("udpPort", listenPort);
// Load calibration from flash (if present)
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
for (int i = 0; i < 5; i++) {
String key = "cal_" + String(ch) + "_" + String(i);
float val = prefs.getFloat(key.c_str(), calibratedPoints[ch][i]);
calibratedPoints[ch][i] = val;
}
}
lightingHue = prefs.getUChar("light_hue", 0);
lightingSaturation = prefs.getUChar("light_sat", 255);
lightingBrightnessSaved = prefs.getUChar("light_bright",255);
lightingBrightness = lightingBrightnessSaved;
Serial.printf("Lighting loaded (0255): H=%d S=%d B=%d\n",
lightingHue, lightingSaturation, lightingBrightness);
// FastLED init
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(255); // full brightness; HSV V controls actual output
// Apply lighting immediately at boot
applyLighting();
// Start UDP with runtime port
udp.begin(udpPort);
Serial.print("Listening on UDP port ");
Serial.println(udpPort);
lastPacketTime = millis();
connectionState = STATE_DISCONNECTED;
updateConnectionStatusUI(connectionState);
}
// -------------------------------
// UDP parsing
// -------------------------------
void coreHandleUDP() {
int packetSize = udp.parsePacket();
if (packetSize <= 0) return;
char buf[256];
int len = udp.read(buf, sizeof(buf) - 1);
buf[len] = '\0';
float values[NUM_CHANNELS] = {0};
int idx = 0;
char* token = strtok(buf, ",");
while (token != nullptr && idx < NUM_CHANNELS) {
values[idx] = atof(token);
idx++;
token = strtok(nullptr, ",");
}
if (idx != NUM_CHANNELS) return;
if (connectionState == STATE_DISCONNECTED) {
// First valid packet after being disconnected → start CONNECTING
Serial.println("STATE CHANGE: DISCONNECTED → CONNECTING (UDP connection established)");
connectionState = STATE_CONNECTING;
updateConnectionStatusUI(connectionState);
startLightingFade(0, lightingBrightnessSaved, LIGHTING_FADE_IN_DURATION);
// Initialize fade-in: start from 0 on all non-override channels
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (!overrideActive[ch]) {
currentDuty[ch] = 0.0f;
}
}
lastPacketTime = millis(); // prevent watchdog during fade-in
}
else if (connectionState == STATE_CONNECTING) {
// Ignore UDP values during fade-in, just keep watchdog alive
lastPacketTime = millis();
}
else if (connectionState == STATE_WAIT_FOR_FIRST_PACKET) {
// First real packet after fade-in completes
Serial.println("STATE CHANGE: WAIT_FOR_FIRST_PACKET → CONNECTED (first UDP packet received)");
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (!overrideActive[ch]) {
targetDuty[ch] = values[ch];
slewStartDuty[ch] = currentDuty[ch]; // currently ~100%
}
}
slewStartTime = millis();
lastPacketTime = millis();
connectionState = STATE_CONNECTED;
updateConnectionStatusUI(connectionState);
// Debug output
Serial.println("Received UDP packet (first after fade-in):");
for (int i = 0; i < NUM_CHANNELS; i++) {
Serial.print(" CH");
Serial.print(i);
Serial.print(" (");
Serial.print(channelLabels[i]);
Serial.print("): ");
Serial.print(values[i], 2);
if (channelUnits[i][0] != '\0') {
Serial.print(" ");
Serial.print(channelUnits[i]);
}
Serial.println();
}
Serial.println();
}
else if (connectionState == STATE_CONNECTED) {
// Normal UDP-driven update
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (!overrideActive[ch]) {
targetDuty[ch] = values[ch];
slewStartDuty[ch] = currentDuty[ch];
}
}
slewStartTime = millis();
lastPacketTime = millis();
// Debug output
Serial.println("Received UDP packet:");
for (int i = 0; i < NUM_CHANNELS; i++) {
Serial.print(" CH");
Serial.print(i);
Serial.print(" (");
Serial.print(channelLabels[i]);
Serial.print("): ");
Serial.print(values[i], 2);
if (channelUnits[i][0] != '\0') {
Serial.print(" ");
Serial.print(channelUnits[i]);
}
Serial.println();
}
Serial.println();
}
}
// -------------------------------
// Connection state machine
// -------------------------------
void coreUpdateState() {
unsigned long now = millis();
switch (connectionState) {
case STATE_CONNECTED: {
// Check for lost connection
if (now - lastPacketTime > watchdogTimeout) {
Serial.println("STATE CHANGE: CONNECTED → DISCONNECTED (UDP connection lost)");
connectionState = STATE_DISCONNECTED;
updateConnectionStatusUI(connectionState);
startLightingFade(lightingBrightness, 0, LIGHTING_FADE_OUT_DURATION);
break;
}
// Normal slew-rate limiting
float progress = (float)(now - slewStartTime) / (float)slewDuration;
if (progress > 1.0f) progress = 1.0f;
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (!overrideActive[ch]) {
float newDuty = slewStartDuty[ch] + (targetDuty[ch] - slewStartDuty[ch]) * progress;
currentDuty[ch] = newDuty;
float calibratedDuty = applyCalibration(ch, newDuty);
int duty = (int)((calibratedDuty / 100.0f) * ((1 << pwmResolution) - 1));
ledcWrite(pwmPins[ch], duty);
}
}
} break;
case STATE_CONNECTING: {
// If we lose packets even while connecting, fall back to DISCONNECTED
if (now - lastPacketTime > watchdogTimeout) {
Serial.println("STATE CHANGE: CONNECTING → DISCONNECTED (no packets during fade-in)");
connectionState = STATE_DISCONNECTED;
updateConnectionStatusUI(connectionState);
break;
}
// Fade-in animation: 0 → 100% on all non-override channels
if (now - lastFadeTime >= FADE_INTERVAL) {
lastFadeTime = now;
bool allReached = true;
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (!overrideActive[ch]) {
float cd = currentDuty[ch];
// Exponential approach to 100%
cd = cd * FADE_IN_FACTOR + 100.0f * (1.0f - FADE_IN_FACTOR);
currentDuty[ch] = cd;
float calibratedDuty = applyCalibration(ch, cd);
int duty = (int)((calibratedDuty / 100.0f) * ((1 << pwmResolution) - 1));
ledcWrite(pwmPins[ch], duty);
// Check if we're close enough to 100%
if (cd < 99.0f) {
allReached = false;
}
}
}
if (allReached) {
Serial.println("STATE CHANGE: CONNECTING → STATE_WAIT_FOR_FIRST_PACKET (fade-in complete)");
connectionState = STATE_WAIT_FOR_FIRST_PACKET;
updateConnectionStatusUI(connectionState);
}
}
} break;
case STATE_WAIT_FOR_FIRST_PACKET: {
// Hold at ~100%, do nothing until first UDP packet arrives
// (handled in coreHandleUDP)
} break;
case STATE_DISCONNECTED: {
// Watchdog fade-to-zero (always active in this state)
if (now - lastFadeTime >= FADE_INTERVAL) {
lastFadeTime = now;
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (currentDuty[ch] > 0.0f) {
currentDuty[ch] *= FADE_OUT_FACTOR;
if (currentDuty[ch] < 0.01f)
currentDuty[ch] = 0.0f;
float calibratedDuty = applyCalibration(ch, currentDuty[ch]);
int duty = (int)((calibratedDuty / 100.0f) * ((1 << pwmResolution) - 1));
ledcWrite(pwmPins[ch], duty);
}
}
}
} break;
}
if (lightingFading) {
unsigned long now = millis();
unsigned long elapsed = now - lightingFadeStartTime;
if (elapsed >= lightingFadeDuration) {
// Fade complete
lightingBrightness = lightingFadeEnd;
// If fade-out finished, jump to 0
if (lightingFadeEnd == 0) {
lightingBrightness = 0;
}
lightingFading = false;
applyLighting();
return;
}
// Compute normalized progress
float t = (float)elapsed / (float)lightingFadeDuration;
// Gamma correction
const float gamma = 2.2f;
float t_gamma = pow(t, gamma);
// Determine effective fade range
uint8_t start = lightingFadeStart;
uint8_t end = lightingFadeEnd;
// Fade-in: 0 → 13 → 100
if (start == 0 && end > 0) {
start = BRIGHTNESS_MIN_VISIBLE;
}
// Fade-out: 100 → 13 → 0
if (end == 0 && start > 0) {
end = BRIGHTNESS_MIN_VISIBLE;
}
// Interpolate only within the visible range
float raw = start + (end - start) * t_gamma;
// Apply jump logic:
if (lightingFadeStart == 0 && elapsed == 0) {
// Fade-in: first frame → jump to 13
lightingBrightness = BRIGHTNESS_MIN_VISIBLE;
}
else if (lightingFadeEnd == 0 && elapsed + 16 >= lightingFadeDuration) {
// Fade-out: last frame → jump to 0
lightingBrightness = 0;
}
else {
lightingBrightness = raw;
}
applyLighting();
}
}

View File

@@ -0,0 +1,58 @@
#pragma once
#include <WiFiUdp.h>
#include <Preferences.h>
#include <ESPUI.h>
#include "Config.h"
// Global objects
extern WiFiUDP udp;
extern Preferences prefs;
extern int udpPort;
// Duty tracking + slew
extern float currentDuty[NUM_CHANNELS];
extern float targetDuty[NUM_CHANNELS];
extern float slewStartDuty[NUM_CHANNELS];
extern unsigned long slewStartTime;
extern unsigned long lastPacketTime;
extern unsigned long lastFadeTime;
// Calibration
extern float logicalPoints[5];
extern float calibratedPoints[NUM_CHANNELS][5];
extern bool overrideActive[NUM_CHANNELS];
// Lighting parameters (0255 range for now)
extern uint8_t lightingHue; // 0255
extern uint8_t lightingSaturation; // 0255
extern uint8_t lightingBrightness; // 0255
extern uint8_t lightingBrightnessSaved;
// State
extern ConnectionState connectionState;
// UI IDs used by Core
extern uint16_t connectionStatusLabel;
extern uint16_t calChannelDropdown;
extern uint16_t calInputs[5];
extern uint16_t calTestValueInput;
extern uint16_t calSaveButton;
extern uint16_t calOverrideSwitch;
extern uint8_t selectedCalChannel;
// Core API
void coreInit(); // called from setup()
void coreHandleUDP(); // called from loop()
void coreUpdateState(); // called from loop()
// Helpers used by UI
float applyCalibration(uint8_t ch, float logicalDuty);
void updateConnectionStatusUI(ConnectionState state);
// Lighting helpers
void applyLighting();
void startLightingFade(uint8_t from, uint8_t to);
// Lighting fade state
extern bool lightingFading;

View File

@@ -0,0 +1,5 @@
#pragma once
#include <ESPUI.h>
#include "Core.h"
void uiInit(uint16_t& tabSettings, uint16_t& tabLighting, uint16_t& tabCalibration);

View File

@@ -0,0 +1,389 @@
#include <Arduino.h>
#include "UI.h"
// Local UI control
static uint16_t portInput;
// -------------------------------
// Calibration UI helpers & callbacks
// -------------------------------
void refreshCalibrationUI() {
for (int i = 0; i < 5; i++) {
ESPUI.updateControlValue(calInputs[i], String(calibratedPoints[selectedCalChannel][i], 2));
}
}
void calChannelCallback(Control *sender, int type) {
// Turn override OFF when switching channels
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
overrideActive[ch] = false;
}
ESPUI.updateControlValue(calOverrideSwitch, "0");
selectedCalChannel = sender->value.toInt();
Serial.print("Calibration channel changed to ");
Serial.println(selectedCalChannel);
refreshCalibrationUI();
}
void calPointCallback(Control *sender, int type) {
int index = sender->id - calInputs[0];
if (index >= 0 && index < 5) {
float val = sender->value.toFloat();
calibratedPoints[selectedCalChannel][index] = val;
Serial.printf("Cal[%d][%d] = %.2f\n", selectedCalChannel, index, val);
}
}
void calTestCallback(Control *sender, int type) {
if (!overrideActive[selectedCalChannel]) return;
float logical = sender->value.toFloat(); // 0100 integer
if (logical < 0) logical = 0;
if (logical > 100) logical = 100;
float calibrated = applyCalibration(selectedCalChannel, logical);
int duty = (int)((calibrated / 100.0f) * ((1 << pwmResolution) - 1));
ledcWrite(pwmPins[selectedCalChannel], duty);
Serial.printf("Override update CH%d: logical=%.2f calibrated=%.2f duty=%d\n",
selectedCalChannel, logical, calibrated, duty);
}
void calSaveCallback(Control *sender, int type) {
Serial.printf("Saving calibration for CH%d...\n", selectedCalChannel);
for (int i = 0; i < 5; i++) {
String key = "cal_" + String(selectedCalChannel) + "_" + String(i);
prefs.putFloat(key.c_str(), calibratedPoints[selectedCalChannel][i]);
}
Serial.println("Calibration saved.");
}
void calOverrideSwitchCallback(Control *sender, int type) {
bool enabled = sender->value.toInt() == 1;
if (enabled) {
Serial.println("Override enabled.");
// Enable override only for the selected channel
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
overrideActive[ch] = (ch == selectedCalChannel);
}
// Immediately apply the test value
float logical = ESPUI.getControl(calTestValueInput)->value.toFloat();
if (logical < 0) logical = 0;
if (logical > 100) logical = 100;
float calibrated = applyCalibration(selectedCalChannel, logical);
int duty = (int)((calibrated / 100.0f) * ((1 << pwmResolution) - 1));
ledcWrite(pwmPins[selectedCalChannel], duty);
Serial.printf("Override driving CH%d: logical=%.2f calibrated=%.2f duty=%d\n",
selectedCalChannel, logical, calibrated, duty);
} else {
Serial.println("Override disabled. Returning to UDP control.");
// Disable all overrides
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
overrideActive[ch] = false;
}
}
}
void lightingSaveCallback(Control *sender, int type) {
if (type != B_UP) return; // avoid double-trigger
prefs.putUChar("light_hue", lightingHue);
prefs.putUChar("light_sat", lightingSaturation);
prefs.putUChar("light_bright", lightingBrightnessSaved);
Serial.printf("Lighting saved (0255): H=%d S=%d B=%d\n",
lightingHue, lightingSaturation, lightingBrightness);
}
static int toSlider(uint8_t v) {
return (int)((v / 255.0f) * 100.0f);
}
static uint8_t fromSlider(int v) {
return (uint8_t)((v / 100.0f) * 255.0f);
}
// -------------------------------
// UI init
// -------------------------------
void uiInit(uint16_t& tabSettings, uint16_t& tabLighting, uint16_t& tabCalibration) {
// Create tabs
tabSettings = ESPUI.addControl(ControlType::Tab, "Settings", "Settings");
tabLighting = ESPUI.addControl(ControlType::Tab, "Lighting", "Lighting");
tabCalibration= ESPUI.addControl(ControlType::Tab, "Calibration", "Calibration");
// Restart button callback
auto restartCallback = [](Control *sender, int type) {
ESP.restart();
};
// Port input callback
auto portInputCallback = [](Control *sender, int type) {
Serial.print("Port input changed to: ");
Serial.println(sender->value);
};
// Save & Apply callback
auto savePortCallback = [](Control *sender, int type) {
if (type != B_UP) return; // Prevent double-trigger
Control* c = ESPUI.getControl(portInput);
int newPort = c->value.toInt();
if (newPort < 1024 || newPort > 65535) {
Serial.println("Invalid port (102465535)");
return;
}
prefs.putInt("udpPort", newPort);
udpPort = newPort;
udp.stop();
udp.begin(udpPort);
Serial.print("New UDP port applied: ");
Serial.println(udpPort);
ESPUI.updateControlValue(portInput, String(newPort));
};
// -------------------------------------------
// Connection Status section
// -------------------------------------------
ESPUI.addControl(
ControlType::Separator,
"Connection Status",
"",
ControlColor::None,
tabSettings
);
// Live-updating connection status label
connectionStatusLabel = ESPUI.addControl(
ControlType::Label,
"Status",
"Disconnected",
ControlColor::Wetasphalt,
tabSettings
);
// -------------------------------------------
// UDP Telemetry Connection Settings section
// -------------------------------------------
ESPUI.addControl(
ControlType::Separator,
"UDP Telemetry Connection Settings",
"",
ControlColor::None,
tabSettings
);
// UDP Port Number input
portInput = ESPUI.addControl(
ControlType::Number,
"UDP Port",
String(udpPort),
ControlColor::Peterriver,
tabSettings,
portInputCallback
);
// Save & Apply button
ESPUI.addControl(
ControlType::Button,
"UDP Port",
"Save & Apply",
ControlColor::Emerald,
tabSettings,
savePortCallback
);
// Existing separator (leave as-is)
ESPUI.addControl(
ControlType::Separator,
"",
"",
ControlColor::None,
tabSettings
);
// Restart button (unchanged)
ESPUI.addControl(
ControlType::Button,
"Restart ESP32",
"Restart",
ControlColor::Alizarin,
tabSettings,
restartCallback
);
// -------------------------------
// Lighting Controls
// -------------------------------
// Hue slider (0360)
ESPUI.addControl(
ControlType::Slider,
"Hue",
String(toSlider(lightingHue)),
ControlColor::Sunflower,
tabLighting,
[](Control *sender, int type) {
int sliderVal = sender->value.toInt(); // 0100
lightingHue = fromSlider(sliderVal); // convert to 0255
Serial.printf("Lighting Hue changed (RAM only): %d\n", lightingHue);
lightingFading = false; // cancel fade if user moves slider
applyLighting();
}
);
// Saturation slider (0100)
ESPUI.addControl(
ControlType::Slider,
"Saturation",
String(toSlider(lightingSaturation)),
ControlColor::Carrot,
tabLighting,
[](Control *sender, int type) {
int sliderVal = sender->value.toInt(); // 0100
lightingSaturation = fromSlider(sliderVal);
Serial.printf("Lighting Saturation updated (RAM only): %d\n", lightingSaturation);
lightingFading = false; // cancel fade if user moves slider
applyLighting();
}
);
// Brightness slider (0100)
ESPUI.addControl(
ControlType::Slider,
"Brightness",
String(toSlider(lightingBrightness)),
ControlColor::Emerald,
tabLighting,
[](Control *sender, int type) {
int sliderVal = sender->value.toInt(); // 0100
lightingBrightnessSaved = fromSlider(sliderVal);
lightingBrightness = lightingBrightnessSaved;
Serial.printf("Lighting Brightness updated (RAM only): %d\n", lightingBrightness);
lightingFading = false; // cancel fade if user moves slider
applyLighting();
}
);
ESPUI.addControl(
ControlType::Button,
"Save Lighting Settings",
"Save",
ControlColor::Emerald,
tabLighting,
lightingSaveCallback
);
// -------------------------------
// Calibration tab UI
// -------------------------------
// Channel selector
calChannelDropdown = ESPUI.addControl(
ControlType::Select,
"Selected Channel",
"0",
ControlColor::Peterriver,
tabCalibration,
calChannelCallback
);
// Add options 07
for (int i = 0; i < NUM_CHANNELS; i++) {
char value[8];
snprintf(value, sizeof(value), "%d", i);
ESPUI.addControl(
ControlType::Option,
channelDropdownLabels[i], // static label
value, // static-ish value (OK)
ControlColor::None,
calChannelDropdown
);
}
ESPUI.addControl(
ControlType::Separator,
"",
"",
ControlColor::None,
tabCalibration
);
// Calibration inputs
const char* calNames[5] = {"0%", "25%", "50%", "75%", "100%"};
for (int i = 0; i < 5; i++) {
calInputs[i] = ESPUI.addControl(
ControlType::Number,
calNames[i],
String(calibratedPoints[0][i], 2),
ControlColor::Wetasphalt,
tabCalibration,
calPointCallback
);
}
ESPUI.addControl(
ControlType::Separator,
"",
"",
ControlColor::None,
tabCalibration
);
// Test value input
calTestValueInput = ESPUI.addControl(
ControlType::Slider,
"Test Value",
"50",
ControlColor::Carrot,
tabCalibration,
calTestCallback
);
calOverrideSwitch = ESPUI.addControl(
ControlType::Switcher,
"Override",
"0", // default OFF
ControlColor::Alizarin,
tabCalibration,
calOverrideSwitchCallback
);
ESPUI.addControl(
ControlType::Separator,
"",
"",
ControlColor::None,
tabCalibration
);
// Save button
calSaveButton = ESPUI.addControl(
ControlType::Button,
"Save Calibration",
"Save",
ControlColor::Emerald,
tabCalibration,
calSaveCallback
);
}

View File

@@ -1,370 +1,66 @@
// ------------------------------------------------------ //
// ESP32 8Channel PWM + WiFiManager + OSC (messages + bundles) // IMPORTANT:
// + Slew + MultiPoint Calibration + OSC Packet Queue + Watchdog Sweep // before flashing this the first time,
// ------------------------------------------------------ // upload the "prepareFilesystem.ino" example sketch from ESPUI
// without that critical files are missing
//
// This was done to reduce the PROGMEM footprint of this program
//
#include <Arduino.h> #include <Arduino.h>
#include <WiFi.h> #include <WiFi.h>
#include <WiFiManager.h> #include <WiFiManager.h>
#include <WiFiUdp.h>
#include <OSCMessage.h>
#include <OSCBundle.h>
// ------------------------------- #include <AsyncTCP.h>
// User Configuration #include <ESPAsyncWebServer.h>
// ------------------------------- #include <ESPUI.h>
const uint8_t NUM_CHANNELS = 8; #include "Config.h"
#include "Core.h"
#include "UI.h"
uint8_t pwmPins[NUM_CHANNELS] = {26, 25, 33, 32, 27, 25, 22, 21}; AsyncWebServer server(80);
const uint32_t pwmFrequency = 10000;
const uint8_t pwmResolutionBits = 10;
const uint32_t pwmMax = (1 << pwmResolutionBits) - 1;
// Slew rate: time to change 1% duty (ms)
unsigned long slewPerPercent = 50;
// OSC UDP port
const uint16_t oscPort = 9000;
// Perchannel OSC addresses (configurable)
const char* oscAddresses[NUM_CHANNELS] = {
"/cpu",
"/cputemp",
"/memory",
"/gpu3d",
"/gputemp",
"/vram",
"/netup",
"/netdown"
};
// -------------------------------
// Multipoint Calibration Tables
// -------------------------------
float logicalPoints[5] = {0, 25, 50, 75, 100};
float calibratedPoints[NUM_CHANNELS][5] = {
{0, 25, 50, 75, 99}, // CH0
{0, 24, 49, 74, 98}, // CH1
{0, 26, 51, 76, 99}, // CH2
{0, 25, 50, 75, 97}, // CH3
{0, 25, 50, 75, 99}, // CH4
{0, 24, 50, 74, 98}, // CH5
{0, 25, 49, 75, 97}, // CH6
{0, 26, 50, 76, 99} // CH7
};
// -------------------------------
// Internal Variables
// -------------------------------
float currentDuty[NUM_CHANNELS] = {0}; // logical 0100
float targetDuty[NUM_CHANNELS] = {0}; // logical 0100
unsigned long lastSlewUpdate = 0;
WiFiUDP Udp;
// -------------------------------
// OSC Packet Queue
// -------------------------------
#define OSC_QUEUE_SIZE 16
#define OSC_MAX_PACKET 512
struct OscPacket {
int len;
uint8_t data[OSC_MAX_PACKET];
};
OscPacket oscQueue[OSC_QUEUE_SIZE];
volatile int oscHead = 0;
volatile int oscTail = 0;
void enqueueOscPacket() {
int packetSize = Udp.parsePacket();
if (packetSize <= 0) return;
int nextHead = (oscHead + 1) % OSC_QUEUE_SIZE;
if (nextHead == oscTail) {
// Queue full → drop packet
return;
}
OscPacket &pkt = oscQueue[oscHead];
pkt.len = Udp.read(pkt.data, OSC_MAX_PACKET);
oscHead = nextHead;
}
bool dequeueOscPacket(OscPacket &pkt) {
if (oscTail == oscHead) return false;
pkt = oscQueue[oscTail];
oscTail = (oscTail + 1) % OSC_QUEUE_SIZE;
return true;
}
// -------------------------------
// Watchdog + Sweep (smooth using slewPerPercent)
// -------------------------------
unsigned long lastOscTime = 0;
unsigned long watchdogTimeoutMs = 5000; // configurable
unsigned long lastSweepStep = 0;
int sweepValue = 0;
int sweepDirection = 1; // +1 or -1
bool inSweepMode = false;
void updateWatchdogAndSweep() {
unsigned long now = millis();
// Enter sweep mode if no OSC for watchdogTimeoutMs
if (!inSweepMode && (now - lastOscTime > watchdogTimeoutMs)) {
inSweepMode = true;
Serial.println("Watchdog: No OSC, entering sweep mode.");
sweepValue = 0;
sweepDirection = 1;
lastSweepStep = now;
}
// Exit sweep mode immediately when OSC resumes
if (inSweepMode && (now - lastOscTime <= watchdogTimeoutMs)) {
inSweepMode = false;
Serial.println("Watchdog: OSC resumed, exiting sweep mode.");
}
// Smooth sweep using the same slew timing as boot-up
if (inSweepMode && (now - lastSweepStep >= slewPerPercent)) {
lastSweepStep = now;
sweepValue += sweepDirection;
if (sweepValue >= 100) {
sweepValue = 100;
sweepDirection = -1;
} else if (sweepValue <= 0) {
sweepValue = 0;
sweepDirection = 1;
}
// Set all channels to the sweep target
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
targetDuty[ch] = sweepValue;
}
}
}
// -------------------------------
// Multipoint calibration function
// -------------------------------
float applyCalibration(uint8_t ch, float logicalDuty) {
if (logicalDuty <= 0) return calibratedPoints[ch][0];
if (logicalDuty >= 100) return calibratedPoints[ch][4];
for (int i = 0; i < 4; i++) {
if (logicalDuty >= logicalPoints[i] && logicalDuty <= logicalPoints[i+1]) {
float x0 = logicalPoints[i];
float x1 = logicalPoints[i+1];
float y0 = calibratedPoints[ch][i];
float y1 = calibratedPoints[ch][i+1];
float t = (logicalDuty - x0) / (x1 - x0);
return y0 + t * (y1 - y0); // linear interpolation
}
}
return calibratedPoints[ch][4];
}
// -------------------------------
// OSC routing (single message)
// -------------------------------
void routeOscMessage(OSCMessage &msg) {
// Any valid OSC message resets watchdog and exits sweep mode
lastOscTime = millis();
for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
if (msg.fullMatch(oscAddresses[ch])) {
float v = 0.0f;
if (msg.isFloat(0)) {
v = msg.getFloat(0);
} else if (msg.isInt(0)) {
v = (float)msg.getInt(0);
} else {
return;
}
if (v < 0.0f) v = 0.0f;
if (v > 1.0f) v = 1.0f;
targetDuty[ch] = v * 100.0f;
Serial.print("OSC CH");
Serial.print(ch);
Serial.print(" -> ");
Serial.println(targetDuty[ch]);
}
}
}
// -------------------------------
// Process OSC queue (messages + bundles)
// -------------------------------
void processOscQueue() {
OscPacket pkt;
while (dequeueOscPacket(pkt)) {
OSCBundle bundle;
OSCMessage msg;
for (int i = 0; i < pkt.len; i++) {
bundle.fill(pkt.data[i]);
msg.fill(pkt.data[i]);
}
if (!bundle.hasError()) {
for (int i = 0; i < bundle.size(); i++) {
OSCMessage *m = bundle.getOSCMessage(i);
if (m) routeOscMessage(*m);
}
}
else if (!msg.hasError()) {
routeOscMessage(msg);
}
}
}
// -------------------------------
// Setup
// -------------------------------
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(500); delay(300);
Serial.println("ESP32 8channel PWM + WiFiManager + OSC + Queue + Watchdog starting..."); Serial.println("Booting Analog System Monitor (UDP + LEDC + WiFiManager + Slew)");
Serial.print("Firmware: ");
Serial.println(FIRMWARE_VERSION);
WiFiManager wifiManager; // WiFi Manager
wifiManager.setHostname("ESP32-PWM-OSC"); WiFiManager wm;
if (!wifiManager.autoConnect("ESP32-PWM-OSC")) { wm.setHostname("AnalogMonitor");
Serial.println("Failed to connect, restarting..."); wm.setTimeout(180);
delay(3000);
Serial.println("Starting WiFiManager...");
bool res = wm.autoConnect("AnalogMonitor-Setup");
if (!res) {
Serial.println("WiFi failed or timed out. Rebooting...");
delay(2000);
ESP.restart(); ESP.restart();
} }
Serial.print("Connected. IP: "); Serial.println("WiFi connected!");
Serial.print("IP: ");
Serial.println(WiFi.localIP()); Serial.println(WiFi.localIP());
Udp.begin(oscPort); // Core init (PWM, prefs, UDP, calibration, state)
Serial.print("Listening for OSC on port "); coreInit();
Serial.println(oscPort);
for (int ch = 0; ch < NUM_CHANNELS; ch++) { // ESPUI Web Interface
ledcAttach(pwmPins[ch], pwmFrequency, pwmResolutionBits); ESPUI.setVerbosity(Verbosity::Verbose);
ledcWrite(pwmPins[ch], 0);
}
delay(100); uint16_t tabSettings, tabLighting, tabCalibration;
uiInit(tabSettings, tabLighting, tabCalibration);
// BOOT-UP SWEEP (all channels together, using slewPerPercent) ESPUI.sliderContinuous = true; // enables live slider updates
for (int d = 0; d <= 100; d++) { ESPUI.beginLITTLEFS("Analog System Monitor UI");
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
float calibratedDuty = applyCalibration(ch, d);
uint32_t pwmValue = (uint32_t)((calibratedDuty / 100.0f) * pwmMax);
ledcWrite(pwmPins[ch], pwmValue);
}
delay(slewPerPercent);
}
for (int d = 100; d >= 0; d--) {
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
float calibratedDuty = applyCalibration(ch, d);
uint32_t pwmValue = (uint32_t)((calibratedDuty / 100.0f) * pwmMax);
ledcWrite(pwmPins[ch], pwmValue);
}
delay(slewPerPercent);
}
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
currentDuty[ch] = 0;
targetDuty[ch] = 0;
ledcWrite(pwmPins[ch], 0);
}
lastOscTime = millis(); // start in normal mode
Serial.println("Ready. OSC + Serial + Queue + Watchdog active.");
} }
// -------------------------------
// Loop
// -------------------------------
void loop() { void loop() {
coreHandleUDP();
unsigned long now = millis(); coreUpdateState();
// Grab any incoming OSC packets into the queue
enqueueOscPacket();
// Process queued OSC packets
processOscQueue();
// Watchdog + sweep mode handling
updateWatchdogAndSweep();
// SERIAL INPUT HANDLING (X=YY)
if (Serial.available()) {
String s = Serial.readStringUntil('\n');
s.trim();
int eq = s.indexOf('=');
if (eq > 0) {
int ch = s.substring(0, eq).toInt();
int val = s.substring(eq + 1).toInt();
if (ch >= 0 && ch < NUM_CHANNELS && val >= 0 && val <= 100) {
targetDuty[ch] = val;
Serial.print("SER CH");
Serial.print(ch);
Serial.print(" -> ");
Serial.println(val);
}
}
}
// CONSTANT-RATE SLEWING (all channels)
if (now - lastSlewUpdate >= slewPerPercent) {
lastSlewUpdate = now;
for (int ch = 0; ch < NUM_CHANNELS; ch++) {
if (currentDuty[ch] < targetDuty[ch]) {
currentDuty[ch] += 1.0f;
if (currentDuty[ch] > targetDuty[ch])
currentDuty[ch] = targetDuty[ch];
}
else if (currentDuty[ch] > targetDuty[ch]) {
currentDuty[ch] -= 1.0f;
if (currentDuty[ch] < targetDuty[ch])
currentDuty[ch] = targetDuty[ch];
}
float calibratedDuty = applyCalibration(ch, currentDuty[ch]);
uint32_t pwmValue = (uint32_t)((calibratedDuty / 100.0f) * pwmMax);
ledcWrite(pwmPins[ch], pwmValue);
}
}
delay(5);
} }

View File

@@ -4,6 +4,9 @@ obj/
out/ out/
publish/ publish/
# Release builds
release/
# Visual Studio / VS Code # Visual Studio / VS Code
.vs/ .vs/
.vscode/ .vscode/

View File

@@ -1,44 +0,0 @@
using System;
using System.IO;
using System.Text.Json;
public class Config
{
public string OscIp { get; set; } = "127.0.0.1";
public int OscPort { get; set; } = 9000;
public int UpdateRateMs { get; set; } = 1000;
private static string ConfigPath =>
Path.Combine(AppContext.BaseDirectory, "config.json");
public static Config Load()
{
try
{
if (File.Exists(ConfigPath))
{
string json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<Config>(json) ?? new Config();
}
}
catch { }
// If loading fails, create a default config
var cfg = new Config();
cfg.Save();
return cfg;
}
public void Save()
{
try
{
string json = JsonSerializer.Serialize(this, new JsonSerializerOptions
{
WriteIndented = true
});
File.WriteAllText(ConfigPath, json);
}
catch { }
}
}

View File

@@ -1,15 +1,12 @@
using System; using System;
using System.Windows.Forms; using System.Windows.Forms;
namespace analog_system_monitor internal static class Program
{ {
internal static class Program [STAThread]
static void Main()
{ {
[STAThread] ApplicationConfiguration.Initialize();
static void Main() Application.Run(new TrayApp());
{
ApplicationConfiguration.Initialize();
Application.Run(new TrayApp());
}
} }
} }

View File

@@ -1,338 +1,278 @@
#nullable enable
using System; using System;
using System.Net.Sockets; using System.Globalization;
using System.Text;
using System.Collections.Generic;
using LibreHardwareMonitor.Hardware; using LibreHardwareMonitor.Hardware;
public class Telemetry public class Telemetry : IDisposable
{ {
private Config config; private const int UpdateRateDefaultMs = 1000;
private string oscIp = "127.0.0.1"; public int UpdateRateMs => UpdateRateDefaultMs;
private int oscPort = 9000;
private readonly UdpSender udp = new UdpSender();
private Computer computer = new Computer();
private IHardware? cpuHw;
private IHardware? gpuHw;
private IHardware? memHw;
private ISensor[] cpuLoadSensors = Array.Empty<ISensor>(); private ISensor[] cpuLoadSensors = Array.Empty<ISensor>();
private ISensor? cpuTempSensor; private ISensor? cpuTempSensor;
private ISensor? gpuVramUsedSensor;
private ISensor? gpuVramTotalSensor;
private ISensor? gpu3DLoadSensor; private ISensor? gpu3DLoadSensor;
private ISensor? gpuTempSensor; private ISensor? gpuTempSensor;
private ISensor? gpuVramUsedSensor;
private ISensor? gpuVramTotalSensor;
private Computer computer = new Computer(); private ISensor? memUsedSensor;
private UdpClient udp = new UdpClient(); private ISensor? memAvailSensor;
// ---------------- INITIALIZATION ---------------- private static readonly CultureInfo CI = CultureInfo.InvariantCulture;
public void Initialize() // Restart LHM every 30 minutes
{ private readonly TimeSpan restartInterval = TimeSpan.FromMinutes(30);
config = Config.Load(); private DateTime lastRestart = DateTime.UtcNow;
// Load defaults from config public void Initialize()
oscIp = config.OscIp;
oscPort = config.OscPort;
// Override with command-line args if provided
ParseArgs(Environment.GetCommandLineArgs());
// Save updated config (optional)
config.OscIp = oscIp;
config.OscPort = oscPort;
config.Save();
computer.IsMemoryEnabled = true;
computer.IsCpuEnabled = true;
computer.IsGpuEnabled = true;
computer.Open();
DetectSensors();
}
public int UpdateRateMs => config.UpdateRateMs;
private void ParseArgs(string[] args)
{
foreach (var arg in args)
{ {
if (arg.StartsWith("--ip=")) ConfigureComputer();
oscIp = arg.Substring("--ip=".Length); computer.Open();
CacheHardwareAndSensors();
if (arg.StartsWith("--port=") &&
int.TryParse(arg.Substring("--port=".Length), out int p))
oscPort = p;
if (arg.StartsWith("--rate=") &&
int.TryParse(arg.Substring("--rate=".Length), out int r))
config.UpdateRateMs = r;
}
}
// ---------------- MAIN UPDATE LOOP ----------------
public void UpdateAndSend()
{
int memPercent = GetMemoryUsagePercent();
int cpuPercent = GetCpuLoadPercent();
int vramPercent = GetGpuVramPercent();
int gpu3DPercent = GetGpu3DLoad();
int cpuTempPercent = GetCpuTemperaturePercent();
int gpuTempPercent = GetGpuTemperaturePercent();
SendOscBundle(
cpuPercent / 100f,
cpuTempPercent / 100f,
memPercent / 100f,
gpu3DPercent / 100f,
gpuTempPercent / 100f,
vramPercent / 100f
);
} }
// ---------------- SENSOR DETECTION ---------------- private void ConfigureComputer()
private void DetectSensors()
{ {
var cpuLoadList = new List<ISensor>(); computer.IsCpuEnabled = true;
computer.IsGpuEnabled = true;
computer.IsMemoryEnabled = true;
ISensor? bestCpuTemp = null; // True minimal mode
ISensor? bestGpuTemp = null; computer.IsMotherboardEnabled = false;
ISensor? bestGpu3D = null; computer.IsControllerEnabled = false;
ISensor? bestVramUsed = null; computer.IsNetworkEnabled = false;
ISensor? bestVramTotal = null; computer.IsStorageEnabled = false;
computer.IsBatteryEnabled = false;
}
private void RestartComputerIfNeeded()
{
if (DateTime.UtcNow - lastRestart < restartInterval)
return;
lastRestart = DateTime.UtcNow;
try
{
computer.Close();
}
catch
{
// ignore
}
computer = new Computer();
ConfigureComputer();
computer.Open();
CacheHardwareAndSensors();
}
private void CacheHardwareAndSensors()
{
cpuHw = null;
gpuHw = null;
memHw = null;
cpuLoadSensors = Array.Empty<ISensor>();
cpuTempSensor = null;
gpu3DLoadSensor = null;
gpuTempSensor = null;
gpuVramUsedSensor = null;
gpuVramTotalSensor = null;
memUsedSensor = null;
memAvailSensor = null;
foreach (var hw in computer.Hardware) foreach (var hw in computer.Hardware)
{ {
hw.Update(); hw.Update();
if (hw.HardwareType == HardwareType.Cpu) switch (hw.HardwareType)
{ {
foreach (var sensor in hw.Sensors) case HardwareType.Cpu:
{ cpuHw = hw;
if (sensor.SensorType == SensorType.Load && CacheCpuSensors(hw);
sensor.Name.Contains("CPU Core")) break;
cpuLoadList.Add(sensor);
if (sensor.SensorType == SensorType.Temperature) case HardwareType.GpuNvidia:
{ case HardwareType.GpuAmd:
if (sensor.Name == "Core (Tctl/Tdie)") case HardwareType.GpuIntel:
bestCpuTemp = sensor; gpuHw = hw;
else if (bestCpuTemp == null) CacheGpuSensors(hw);
bestCpuTemp = sensor; break;
}
}
}
if (hw.HardwareType == HardwareType.GpuNvidia || case HardwareType.Memory:
hw.HardwareType == HardwareType.GpuAmd || memHw = hw;
hw.HardwareType == HardwareType.GpuIntel) CacheMemorySensors(hw);
{ break;
foreach (var sensor in hw.Sensors)
{
if (sensor.SensorType == SensorType.Temperature)
{
if (sensor.Name == "GPU Core")
bestGpuTemp = sensor;
else if (bestGpuTemp == null)
bestGpuTemp = sensor;
}
if (sensor.SensorType == SensorType.Load)
{
if (sensor.Name == "D3D 3D")
bestGpu3D = sensor;
else if (bestGpu3D == null && sensor.Name == "GPU Core")
bestGpu3D = sensor;
}
if (sensor.SensorType == SensorType.SmallData)
{
if (sensor.Name == "GPU Memory Used")
bestVramUsed = sensor;
if (sensor.Name == "GPU Memory Total")
bestVramTotal = sensor;
}
}
} }
} }
cpuLoadSensors = cpuLoadList.ToArray();
cpuTempSensor = bestCpuTemp;
gpuTempSensor = bestGpuTemp;
gpu3DLoadSensor = bestGpu3D;
gpuVramUsedSensor = bestVramUsed;
gpuVramTotalSensor = bestVramTotal;
} }
// ---------------- METRICS ---------------- private void CacheCpuSensors(IHardware hw)
private int GetMemoryUsagePercent()
{ {
float used = 0; var loads = new System.Collections.Generic.List<ISensor>();
float available = 0;
foreach (var hw in computer.Hardware) foreach (var s in hw.Sensors)
{ {
if (hw.HardwareType == HardwareType.Memory) if (s.SensorType == SensorType.Load &&
s.Name.Contains("CPU Core"))
loads.Add(s);
if (s.SensorType == SensorType.Temperature)
{ {
hw.Update(); if (s.Name == "Core (Tctl/Tdie)")
cpuTempSensor = s;
foreach (var sensor in hw.Sensors) else if (cpuTempSensor == null)
{ cpuTempSensor = s;
if (sensor.SensorType == SensorType.Data && sensor.Name == "Memory Used")
used = sensor.Value ?? 0;
if (sensor.SensorType == SensorType.Data && sensor.Name == "Memory Available")
available = sensor.Value ?? 0;
}
} }
} }
float total = used + available; cpuLoadSensors = loads.ToArray();
if (total <= 0) return 0;
return (int)Math.Round((used / total) * 100);
} }
private int GetCpuLoadPercent() private void CacheGpuSensors(IHardware hw)
{
foreach (var s in hw.Sensors)
{
if (s.SensorType == SensorType.Load)
{
if (s.Name == "D3D 3D")
gpu3DLoadSensor = s;
else if (gpu3DLoadSensor == null && s.Name == "GPU Core")
gpu3DLoadSensor = s;
}
if (s.SensorType == SensorType.Temperature)
{
if (s.Name == "GPU Core")
gpuTempSensor = s;
else if (gpuTempSensor == null)
gpuTempSensor = s;
}
if (s.SensorType == SensorType.SmallData)
{
if (s.Name == "GPU Memory Used")
gpuVramUsedSensor = s;
if (s.Name == "GPU Memory Total")
gpuVramTotalSensor = s;
}
}
}
private void CacheMemorySensors(IHardware hw)
{
foreach (var s in hw.Sensors)
{
if (s.SensorType == SensorType.Data && s.Name == "Memory Used")
memUsedSensor = s;
if (s.SensorType == SensorType.Data && s.Name == "Memory Available")
memAvailSensor = s;
}
}
public void UpdateAndSend()
{
RestartComputerIfNeeded();
//cpuHw?.Update();
//gpuHw?.Update();
//memHw?.Update();
// Single-pass update: update ALL enabled hardware in one loop
foreach (var hw in computer.Hardware)
hw.Update();
float cpu = GetCpuLoadPercent();
float cpuTemp = GetCpuTemperaturePercent();
float mem = GetMemoryUsagePercent();
float gpu3d = GetGpu3DLoad();
float gpuTemp = GetGpuTemperaturePercent();
float vram = GetGpuVramPercent();
float[] packet =
{
cpu,
cpuTemp,
mem,
gpu3d,
gpuTemp,
vram,
0f,
0f
};
udp.SendFloats(packet);
}
private float GetCpuLoadPercent()
{ {
if (cpuLoadSensors.Length == 0) return 0; if (cpuLoadSensors.Length == 0) return 0;
float total = 0; float total = 0;
int count = 0; int count = 0;
foreach (var sensor in cpuLoadSensors) foreach (var s in cpuLoadSensors)
{ {
sensor.Hardware.Update(); total += s.Value ?? 0;
total += sensor.Value ?? 0;
count++; count++;
} }
return count == 0 ? 0 : (int)Math.Round(total / count); return count == 0 ? 0 : total / count;
} }
private int GetGpuVramPercent() private float GetCpuTemperaturePercent()
{ {
if (gpuVramUsedSensor == null || gpuVramTotalSensor == null) float t = cpuTempSensor?.Value ?? 0;
return 0; return Math.Clamp(t, 0, 100);
gpuVramUsedSensor.Hardware.Update();
float usedMB = gpuVramUsedSensor.Value ?? 0;
float totalMB = gpuVramTotalSensor.Value ?? 0;
if (totalMB <= 0) return 0;
return (int)Math.Round((usedMB / totalMB) * 100);
} }
private int GetGpu3DLoad() private float GetGpu3DLoad()
{ {
if (gpu3DLoadSensor == null) return 0; return gpu3DLoadSensor?.Value ?? 0;
gpu3DLoadSensor.Hardware.Update();
return (int)Math.Round(gpu3DLoadSensor.Value ?? 0);
} }
private int GetCpuTemperaturePercent() private float GetGpuTemperaturePercent()
{ {
if (cpuTempSensor == null) return 0; float t = gpuTempSensor?.Value ?? 0;
return Math.Clamp(t, 0, 100);
cpuTempSensor.Hardware.Update();
float temp = cpuTempSensor.Value ?? 0;
return (int)Math.Round(Math.Clamp(temp, 0, 100));
} }
private int GetGpuTemperaturePercent() private float GetGpuVramPercent()
{ {
if (gpuTempSensor == null) return 0; float used = gpuVramUsedSensor?.Value ?? 0;
float total = gpuVramTotalSensor?.Value ?? 0;
gpuTempSensor.Hardware.Update(); if (total <= 0) return 0;
float temp = gpuTempSensor.Value ?? 0; return (used / total) * 100f;
return (int)Math.Round(Math.Clamp(temp, 0, 100));
} }
// ---------------- OSC ---------------- private float GetMemoryUsagePercent()
private void SendOscBundle(
float cpu, float cpuTemp, float mem,
float gpu3d, float gpuTemp, float vram)
{ {
var messages = new List<byte[]>(); float used = memUsedSensor?.Value ?? 0;
float avail = memAvailSensor?.Value ?? 0;
messages.Add(BuildOscFloatMessage("/cpu", cpu)); float total = used + avail;
messages.Add(BuildOscFloatMessage("/cputemp", cpuTemp)); if (total <= 0) return 0;
messages.Add(BuildOscFloatMessage("/memory", mem));
messages.Add(BuildOscFloatMessage("/gpu3d", gpu3d));
messages.Add(BuildOscFloatMessage("/gputemp", gpuTemp));
messages.Add(BuildOscFloatMessage("/vram", vram));
byte[] bundle = BuildOscBundle(messages); return (used / total) * 100f;
udp.Send(bundle, bundle.Length, oscIp, oscPort);
} }
private byte[] BuildOscBundle(List<byte[]> messages) public void Dispose()
{ {
List<byte[]> parts = new List<byte[]>(); udp.Dispose();
computer.Close();
parts.Add(PadOscString("#bundle"));
byte[] timetag = new byte[8];
timetag[7] = 1;
parts.Add(timetag);
foreach (var msg in messages)
{
byte[] len = BitConverter.GetBytes(msg.Length);
if (BitConverter.IsLittleEndian)
Array.Reverse(len);
parts.Add(len);
parts.Add(msg);
}
int total = 0;
foreach (var p in parts)
total += p.Length;
byte[] bundle = new byte[total];
int offset = 0;
foreach (var p in parts)
{
Buffer.BlockCopy(p, 0, bundle, offset, p.Length);
offset += p.Length;
}
return bundle;
}
private byte[] BuildOscFloatMessage(string address, float value)
{
byte[] addr = PadOscString(address);
byte[] types = PadOscString(",f");
byte[] floatBytes = BitConverter.GetBytes(value);
if (BitConverter.IsLittleEndian)
Array.Reverse(floatBytes);
byte[] packet = new byte[addr.Length + types.Length + floatBytes.Length];
Buffer.BlockCopy(addr, 0, packet, 0, addr.Length);
Buffer.BlockCopy(types, 0, packet, addr.Length, types.Length);
Buffer.BlockCopy(floatBytes, 0, packet, addr.Length + types.Length, floatBytes.Length);
return packet;
}
private byte[] PadOscString(string s)
{
byte[] raw = Encoding.ASCII.GetBytes(s);
int paddedLength = ((raw.Length + 1 + 3) / 4) * 4;
byte[] padded = new byte[paddedLength];
Buffer.BlockCopy(raw, 0, padded, 0, raw.Length);
return padded;
} }
} }

View File

@@ -1,86 +1,148 @@
#nullable enable
using System; using System;
using System.Diagnostics;
using System.Drawing; using System.Drawing;
using System.Reflection; using System.IO;
using System.Threading;
using System.Windows.Forms; using System.Windows.Forms;
using Microsoft.Win32;
namespace analog_system_monitor public class TrayApp : ApplicationContext
{ {
public class TrayApp : ApplicationContext private NotifyIcon trayIcon;
private Telemetry telemetry;
private System.Windows.Forms.Timer timer;
private bool telemetryPaused = false;
public TrayApp()
{ {
private NotifyIcon trayIcon; telemetry = new Telemetry();
private Thread workerThread; telemetry.Initialize();
private bool running = true;
public TrayApp() trayIcon = new NotifyIcon()
{ {
trayIcon = new NotifyIcon() Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath),
{ Visible = true,
Icon = LoadEmbeddedIcon(), Text = "Telemetry Running (UDP)"
Text = "Analog System Monitor", };
Visible = true,
ContextMenuStrip = BuildMenu()
};
workerThread = new Thread(WorkerLoop) var menu = new ContextMenuStrip();
{
IsBackground = true
};
workerThread.Start();
}
private Icon LoadEmbeddedIcon() // Show config.json
menu.Items.Add("Show Config", null, OnShowConfig);
// Reload config
menu.Items.Add("Reload Config", null, OnReloadConfig);
// Separator
menu.Items.Add(new ToolStripSeparator());
// Exit
menu.Items.Add("Exit", null, OnExit);
trayIcon.ContextMenuStrip = menu;
// Main telemetry timer
timer = new System.Windows.Forms.Timer();
timer.Interval = 1000;
timer.Tick += (s, e) =>
{ {
var assembly = Assembly.GetExecutingAssembly(); if (!telemetryPaused)
var resourceName = "analog_system_monitor.telemetry_icon.ico";
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
{
MessageBox.Show("Embedded icon not found: " + resourceName);
return SystemIcons.Application;
}
return new Icon(stream);
}
private ContextMenuStrip BuildMenu()
{
var menu = new ContextMenuStrip();
var exitItem = new ToolStripMenuItem("Exit");
exitItem.Click += (s, e) => ExitApp();
menu.Items.Add(exitItem);
return menu;
}
private void WorkerLoop()
{
var telemetry = new Telemetry();
telemetry.Initialize();
while (running)
{
telemetry.UpdateAndSend(); telemetry.UpdateAndSend();
Thread.Sleep(telemetry.UpdateRateMs); };
} timer.Start();
}
private void ExitApp() // Detect system sleep/wake
SystemEvents.PowerModeChanged += OnPowerModeChanged;
}
private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e)
{
if (e.Mode == PowerModes.Suspend)
{ {
running = false; telemetryPaused = true;
}
else if (e.Mode == PowerModes.Resume)
{
telemetryPaused = true;
try // Give Windows time to restore networking
var resumeTimer = new System.Windows.Forms.Timer();
resumeTimer.Interval = 3000; // 3 seconds
resumeTimer.Tick += (s, ev) =>
{ {
workerThread?.Join(500); telemetryPaused = false;
} resumeTimer.Stop();
catch { } resumeTimer.Dispose();
};
trayIcon.Visible = false; resumeTimer.Start();
trayIcon.Dispose();
Application.Exit();
} }
} }
// Show config.json in Explorer
private void OnShowConfig(object? sender, EventArgs e)
{
try
{
string exeDir = AppContext.BaseDirectory;
string cfgPath = Path.Combine(exeDir, "config.json");
if (File.Exists(cfgPath))
{
Process.Start("explorer.exe", $"/select,\"{cfgPath}\"");
}
else
{
MessageBox.Show(
"config.json not found.",
"Show Config",
MessageBoxButtons.OK,
MessageBoxIcon.Warning
);
}
}
catch (Exception ex)
{
MessageBox.Show(
$"Failed to open config.json:\n{ex.Message}",
"Show Config Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
}
}
// Reload config handler
private void OnReloadConfig(object? sender, EventArgs e)
{
try
{
telemetry.Dispose();
telemetry = new Telemetry();
telemetry.Initialize();
MessageBox.Show(
"Configuration reloaded successfully.",
"Reload Config",
MessageBoxButtons.OK,
MessageBoxIcon.Information
);
}
catch (Exception ex)
{
MessageBox.Show(
$"Failed to reload configuration:\n{ex.Message}",
"Reload Config Error",
MessageBoxButtons.OK,
MessageBoxIcon.Error
);
}
}
private void OnExit(object? sender, EventArgs e)
{
telemetry.Dispose();
trayIcon.Visible = false;
Application.Exit();
}
} }

View File

@@ -0,0 +1,85 @@
#nullable enable
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
public class UdpSender : IDisposable
{
private readonly UdpClient client = new UdpClient();
private IPEndPoint endpoint;
private const string DefaultIp = "192.168.1.50";
private const int DefaultPort = 12345;
public UdpSender()
{
string exeDir = AppContext.BaseDirectory;
string cfgPath = Path.Combine(exeDir, "config.json");
// Create default config if missing
if (!File.Exists(cfgPath))
{
var defaultCfg = new UdpConfig
{
esp32_ip = DefaultIp,
esp32_port = DefaultPort
};
string json = JsonSerializer.Serialize(
defaultCfg,
new JsonSerializerOptions { WriteIndented = true }
);
File.WriteAllText(cfgPath, json);
}
// Load config
var jsonText = File.ReadAllText(cfgPath);
var cfg = JsonSerializer.Deserialize<UdpConfig>(jsonText)
?? throw new Exception("Invalid config.json");
endpoint = new IPEndPoint(IPAddress.Parse(cfg.esp32_ip), cfg.esp32_port);
}
public void SendFloats(float[] values)
{
try
{
string packet = string.Join(",", values);
byte[] data = System.Text.Encoding.ASCII.GetBytes(packet);
client.Send(data, data.Length, endpoint);
}
catch (SocketException ex) when (
ex.SocketErrorCode == SocketError.NetworkUnreachable ||
ex.SocketErrorCode == SocketError.HostUnreachable ||
ex.SocketErrorCode == SocketError.NetworkDown ||
ex.SocketErrorCode == SocketError.AddressNotAvailable)
{
// Network not ready (sleep, reconnecting, etc.)
// Skip this tick silently.
}
catch (ObjectDisposedException)
{
// App is shutting down — ignore.
}
catch (Exception)
{
// Any other unexpected error — swallow to avoid crashing the tray app.
}
}
public void Dispose()
{
client.Dispose();
}
private class UdpConfig
{
public string esp32_ip { get; set; } = DefaultIp;
public int esp32_port { get; set; } = DefaultPort;
}
}

View File

@@ -4,20 +4,29 @@
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework> <TargetFramework>net10.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <!-- Single-file EXE -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<DebugType>None</DebugType> <!-- No trimming (LHM + reflection will break) -->
<DebugSymbols>false</DebugSymbols> <PublishTrimmed>false</PublishTrimmed>
<ApplicationIcon>telemetry_icon.ico</ApplicationIcon>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Keep debugging symbols optional -->
<DebugType>none</DebugType>
<DebugSymbols>false</DebugSymbols>
<ApplicationIcon>telemetry_icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" /> <PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
<None Include="telemetry_icon.ico" /> <None Include="telemetry_icon.ico" />
<EmbeddedResource Include="telemetry_icon.ico" /> <EmbeddedResource Include="telemetry_icon.ico" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity
version="1.0.0.0"
processorArchitecture="*"
name="AnalogSystemMonitor"
type="win32"
/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<!-- Force the EXE to always run as administrator -->
<requestedExecutionLevel
level="requireAdministrator"
uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
</assembly>

View File

@@ -0,0 +1,53 @@
# ============================================
# Build Release Single-File EXE for Analog System Monitor
# Output directory: ./release/
# ============================================
Write-Host "=== Analog System Monitor Release Build ===" -ForegroundColor Cyan
# Ensure script runs from project directory
$project = "analog_system_monitor.csproj"
if (!(Test-Path $project)) {
Write-Host "Error: Telemetry.csproj not found in this directory." -ForegroundColor Red
exit 1
}
# Clean previous builds
Write-Host "Cleaning previous build artifacts..."
dotnet clean -c Release
# Publish using settings from the .csproj
Write-Host "Publishing Release build..."
dotnet publish -c Release
# Determine publish folder
$publishDir = Join-Path "bin" "Release\net10.0-windows\win-x64\publish"
if (!(Test-Path $publishDir)) {
Write-Host "Error: Publish directory not found." -ForegroundColor Red
exit 1
}
# Custom output directory
$releaseDir = Join-Path (Get-Location) "release"
# Create directory if missing
if (!(Test-Path $releaseDir)) {
Write-Host "Creating release directory..."
New-Item -ItemType Directory -Path $releaseDir | Out-Null
}
# Copy all published files to ./release/
Write-Host "Copying published files to ./release/ ..."
Copy-Item -Path "$publishDir\*" -Destination $releaseDir -Recurse -Force
Write-Host "Build completed successfully!" -ForegroundColor Green
Write-Host "Release output: $releaseDir"
# List produced files
Write-Host "`nRelease files:"
Get-ChildItem $releaseDir | Format-Table Name, Length
# Open folder in Explorer
Write-Host "`nOpening release folder..."
Start-Process explorer.exe $releaseDir