The (Watching You) Standing Desk
Final Updates
After several long days and nights, I completed my AI standing desk. Here is the summary slide. It is a custom solid cherry hardwood slab table on top of a prebuilt a standing desk frame. While there have been a few previous fabacademy standing desk projects, I did not find any that incorporate AI or used solid wood top. I also did not find any commercial standing desks that incorporate a computer vision system to control desk height, so this may be the first AI-enabled standing desk. I used the following parts (for a total of around $310):
- Metal standing desk frame with prebuilt lifting columns from Amazon - $200,
- 4' x 12" x 1" Unfinished cherry slab top $50 ($12.5 per board foot),
- Large LED arcade buttons - $20,
- G/Flex 655-1 1 oz syringe $20,
- PC817 Optocouplers (50 pack) - $10,
- Arducam OV5647 Camera - $10,
- PC817 Optocouplers (50 pack) - $10,
- VL53L0X Time-of-flight Distance Sensor - inventory,
- 128x64 OLED - inventory,
- Miuzei MG996R Servo Motor - inventory,
- scrap steel from the CBA shop
- polyurethane coating from the CBA shop
- M5 screws from the CBA shop.
In the end, the standing desk PCB was not burnt out and just needed a manual reset to work again. With the extensive help of Gemini, the standing desk has several modes (calibration, manual, timer, face tracking) which are controlled by the three LED buttons. You can see the manual and timer modes in the video below.
Background & Weekly Changelog
I use a standing desk every day at the office to alleviate back pain, so I also wanted to build a standing desk for my apartment on days when I work from home. I drew inspiration from
- Taylorism(productivity optimization),
- ergonomics,
- surveillance capitalism,
- home automation,
- employee productivity monitoring software (bossware),
- Linus Torvalds walking desk,
- Useless machines,
I wanted to subvert/play with ideas of personal productivity while making interactive and functional furniture. The main idea would be to integrate a camera with a computer vision system to detect posture and automatically adjust desk height or create a auditory tone/speech reminder to "sit up straighter" or "stop looking at your phone". Another idea for a feature is that the desk detects how long you have been sitting or standing and switches between to encourage changing positions more often, which I often forget to do when I am focused on a task for too long. For safety, there would be a large E-stop button. For sanity, there would be a disable button to switch off the "smart"/"productivity" features.
Week 1
This week I made simple CAD mock of the standing desk.
Week 2
This week I researched a bit about how standing desks work and what motors are typically used in standing desks. Most desks use brushed DC motors (rather than stepper motors) because they have higher torque for heavier loads. I found this actuator with hall effect sensor but it is very expensive. This is a much cheaper pair of linear actuators but they seem to lack the proximity sensors and do not seem directly programmable with other microcontrollers.
Week 3
I am thinking of using the Pi Pico for the control system since the GPIO pins could have better performance for synchronization of the two actuators that raise and lower the legs of the standing desk. The microcontroller should have the following functionality:
- Control the motor driver, tracking the current position
- Synchronize the two actuators using feedback control loop and hall effect sensors
- Button Input for controlling the height
- Output LCD disply
- Safety features such as stop limits and anti-collison interrupts by detecting current spikes
- Add emergency stop for safety
Week 4
This week I had a chat with Dan about my final project and he challenged me to think of more features for my standing desk. One idea I had is to make the desk more interactive and "smart". For instance, there could be a camera that captures information about the user's posture, which could be record for analytics or integrated into more complex desk functionality such as automatically changing desk positions to ensure that the user is not sitting or standing too long.
I'm trying to figure out the design of the lifting mechanism. I will investigate how lifting columns in standing desks work and how to design the gear box.
- Spiral 1: Build a single, working lifting column with gearbox to translate motor rotation into linear motion.
- Spiral 2: Build a second column and write the software to make the two columns move in perfect sync.
- Spiral 3: Machine the final frame, assemble everything, build the user interface, and refine the performance.
- Spiral 4: Add computer vision to detect posture and add automate lifting functionality.
Week 5
This week I spoke with Anthony about the mechanism and ACME lead screw design would work and what is used in most desks. However the parts would be a little expensive to purchase separately. I found this premade standing desk frame that was actually cheaper than the linear actuators. This would also derisk the lifting mechanism as I don't have any experience and wanted the desk to still be safe to use. Using premade lifting mechanism would also let me focus on the computer vision feature with the ESP32 Sense microcontroller. I will need to reverse engineer the motor driver system to control with the ESP32. If there is time, I can create a custom frame and rebuild the lifting column myself using parts from the standing desk frame.
I look at standing desk projects from previous fabacademy students
- fixed standing desk cut from particle board on CNC.
- another fixed standing desk
- adjustable, non-mechanical standing desk
- plywood standing desk with one linear actuator
- adjustable standing desk with resin top
I didn't have much time to work on the final project as I was busy making the camera PCB this week. The standing desk frame I ordered arrived but I haven't opened it yet. I talk with Anthony and he seemed to think it would be straightforward to hack the existing electronics in the standing desk to be controlled by another PCB such as the XIAO ESP32. Hopefully, I can iterate and integrate the camera PCB I made to both detect posture and control the motor drivers and keep most of the existing safety funtionality from the standing desk frame collision detection. I also found this Edge Impulse machine learning (ML) platform for edge devices, which seems to make it easier to train and deploy ML models on the XIAO ESP32S3. Maybe, next week I can go through the image classification tutorial.
Week 7
This week I opened the case for the standing desk PCB to take a look. I am thinking to use the logic analyzer to read out the signals of the motor pins to reverse engineer the control signals to send with the XIAO ESP32S3. The other idea would be to manuall control the button presses but that would not allow the ESP32S3 to read the hall effect or desk height position feedback.
Week 8
This week I chatted with Anthony to discuss how to approach reversing the standing desk PCB. There were several options to control the moators that ranged from hard (recreating the motor H-bridges) to easy (manually pressing the buttons). I wanted to get the height information and it turned out to be non-trival to try to decode the height LED display and so I decided the lower-risk route would be to measure the standing height with another sensor and use the XIAO ESP32S3-Sense to directly control the button presses on the standing desk frame PCB. This way I would not have to worry about reverse engineering any components (which I don't have experience with and may accidently damage the standing desk PCB).
Week 9
I settled on Seeed Studio Grove Vision AI V2 board that has dedicated AI processer for running computer vision models. I will follow this tutorial over the next few weeks.
Week 10
For the standing desk frame, I wanted to fabricate the table top from this large cherry slab I had in my basement. There are severe checking (large cracks) in the slab that need to be stabilized with bow ties and possibly epoxy. In the spirit of digital fabrication, I plan to use the waterjet to cut out metal bowties in design inspired by surveillance such as eyes and cameras.
Week 12
I will cut out metal bowties with the water jet.
I also start with parts list for standing desk control unit
- XIAO ESP32S3
- Grove AI V2 module
- OV5647 Camera Module
- VL53L0X Time-of-Flight sensor
- ENS160+AHT21 sensor
- HC-SR501 PIR
- PC817 Optocoupler
- RGB OLED
- USB-C Power breakout board
- Arcade buttons (12V LED)
I plan to use the PIR to detect if person is at desk to turn on auto height face tracking functions and ToF sensor to measure distance from ground from current position. There will likely be several modes (controlled by the buttons) to change functionality such as
- Fixed/static -- desk height does not change
- Auto/tracking -- desk height changes based on face tracking
- Timed -- desk height changes based set interval (sometimes forget to change from sitting to standing and vice versa and changing positions frequently is better than staying in one position for a long period of time
Week 13
For week 13, I developed a mock prototype of the head tracking application with the Grove AI V2 Module and the XIAO ESP32S3-Sense. Detecting posture and eye gaze may be too much work for the time left so
I also discovered that exists 3D printed case for the Grove AI V2 module and camera, which I printed out with the FormLabs Fuse SLS printer in tough 1500 resin.
Week 14
This week, I surface the cherry slab with the ShopBot PRS5 with a 2.3 inch spoilboard bit to surface both sides of two cherry slabs. I set the tool path to cut in depth of cuts of 0.085 inch with 0.75 inch stepover. I found a spindle speed of around 10000 RPM and feedrate of 220 in/min to work well. I did not find a finishing pass to matter too much as surfacing marks were still visible that would need to be cleaned up with sanding.
Week 15
Desk
This week I did a lot and wish I started much earlier as I ran into unanticipated difficulties. First, I completed the tabletop as the finishing with oil-based polyurethane takes 12 hours between coats and I wanted to apply three coats for the desk table.
I spent a lot of time sanding the table top to get rid of surfacing marks and to prepare for finishing. I three different sanding techinques: orbital sander, belt sander, and hand sanding. I found the belt sander to leave scratches even on the highest grit and the orbital sander to leave swirl marks (from applying too much pressure). In the end, I found that hand sanding gave the best result without artifacts although took much more time. I started from 60 grit and worked up to 180 grit.
To secure the crack, I decided to cut out dogbone inlays from steel on the OMAX waterjet in the CBA shop. I found a a piece of scrap metal around 0.37 inches thick that Dan said was from a (lathe?) machine from the 1950s. I made the inlays around 4 inches long with 1 inch diameter ends. Due to not filling up the garnet all the way, the last dogbone did not cut all the way through. Luckily, four inlays were enough to secure all the cracks in the slab. I used the dremel to cut off the tabs.
Here is the STEP file. I glued them in with G-Flex Epoxy finishing with polyurethane. I initally sandblasted the inlays buy had some squeeze out epoxy on the glup up so elected to sand everything flush again before finishing.
I pocketed the outline of the dogboen with the ShopBot PRS5 with a \(\frac{1}{4}\) downcut endmill. I used a spindle speed of 8000 RPM with 100 in/min feedrate and 0.125 in stepdown. I pulled the outline of the dogbone out 0.025 inches to create a gap loose enough to leave room for the epoxy to fill.
The provided metal ends were slightly too long for the width of my table top so I cut to length on the bandsaw and used the drill press to make new screw holes and use the deburring bit and belt sander to soften the edges.
I ended up applying three coats of thin wipe on polyurethane finish onto the top and bottom of the desk. I hand sanded with 320 grit between coats. Here is the final product after the polyurethane finish has cured.
Electronics
For the board, I wanted to use the I have been using throughtout the class XIAO ESP32S3. I planned to use optocouplers for both the standing desk PCB which runs on 29V and the arcade LED buttons which runs on 12V. I also planned to have a VL53L0X ToF sensor to measure the distance to the ground since it would be too difficult to extract that information directly from the standing desk PCB itself. I also added a HC-SR501 PIR to have motino sensing under the desk to turn on the face tracking when feet under the desk are detected. Here are the final KiCAD files.
Alan pointed out that I made a mistake in the schematics by giving the three LED arcade buttons the same variable name of LED_IN so that the trace routing connected all three circuits. So pressing any one button would trigger all buttons and effectively act as one single button to the optocoupler.
I had initially planned to use optocouplers with copper tape to directly connect the standing desk PCB dome button contacts to the XIAO. I even got some tests done but it ended up being too finicky and unreliable to trigger and too difficult to solder so I opted for controlling the buttons mechanically with a servo motor.
I used cable ties, electrical tape, and heatshrink tubes to organize the cables -- using black/brown wires for ground, red for power, blue for SDA, green for SCL, and yellow/orange for LED arcade buttons switches. I 3D printed out housing for the electronics with cutouts for the wires. I use electrical tape to create a tapered friction fit for the arcade buttons. I cut out a piece of scrap wood for the top and how glued M5 nuts on top and fastened to the underside of the standing desk frame. The servo motor was attached with electrical tape with add foam padding between the standing desk PCB case and the motor.
Here is the CAD file for the case.
Programming
I found the I2C with the Grove AI V2 module to be finicky and Jake said that I2C is not made for long distance (several feet in my case), and the I2C bus can be overloaded with noise with many sensors/components (OLED, TOF, GROVE). In the process of programming I realized I switch 5V and 3V3, which was responsible for my issues with I2C and necessitated a new board (also to fix the arcade button issues). I found by profiling that the Grove AI V2 module is very slow and takes over 1 second at 400K clock speed and 0.6 seconds at 100K clock speed. I also tried to get the Grove AI V2 working with UART but was not able to and decided to switch back to I2C. In the end, I also did not use the PIR sensor, as I found the triggering to be unreliable.
I2C Profiler
#include
#include
#include
#include
SSCMA AI;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
VL53L1X tof;
void setup() {
Serial.begin(115200);
while(!Serial);
delay(1000);
Serial.println("\n=== I2C SPEED TEST ===");
Wire.begin();
Wire.setClock(400000); // 400kHz Fast Mode
Serial.print("Init OLED... ");
if(display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("OK");
else Serial.println("FAIL");
Serial.print("Init ToF... ");
tof.setTimeout(500);
if(tof.init()) {
tof.setDistanceMode(VL53L1X::Long);
tof.setMeasurementTimingBudget(50000);
tof.startContinuous(50);
Serial.println("OK");
} else Serial.println("FAIL");
Serial.print("Init AI... ");
if(AI.begin()) Serial.println("OK");
else Serial.println("FAIL");
}
void loop() {
unsigned long start, end;
// 1. Measure ToF Read
start = micros();
tof.read();
end = micros();
Serial.print("ToF Read: "); Serial.print((end-start)/1000.0); Serial.print(" ms | ");
// 2. Measure AI Invoke
start = micros();
AI.invoke(1, false, false);
end = micros();
Serial.print("AI Invoke: "); Serial.print((end-start)/1000.0); Serial.print(" ms | ");
// 3. Measure OLED Draw (Full refresh)
start = micros();
display.clearDisplay();
display.setCursor(0,0); display.print("TEST");
display.display();
end = micros();
Serial.print("OLED Draw: "); Serial.print((end-start)/1000.0); Serial.println(" ms");
delay(1000);
}
Here is the final standing desk code. I used many iterations with Gemini to write the code. There are several modes that change the functionality of the desk. The different modes are navigated by the left/right buttons. The middle button is usually to select or set paramters. The first mode is a calibration mode where the user can set the angle of the servo motor to trigger the up/down buttons on the standing desk in 5˚ increments. They can also set the minimum and maximum heights as well as change threshold for face detection (target head centerline, detection confidence, and face bound box area). The manual mode is toggled on/off by the middle button and simply move the desk up and down with the right and left buttons respectively to mimic the function of the original up/down buttons. The timer mode loops between specified sitting and standing heights in user-chosen intervals. The face-tracking mode automatically move the desk based on face tracking from the Grove AI V2 module. The middle button toggles the reverse mode which change the behavoir to make the desk practically unuseable.
Standing Desk Code
#include <ESP32Servo.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Seeed_Arduino_SSCMA.h>
#include <VL53L1X.h>
#include <Preferences.h>
// --- PINS ---
#define PIN_SERVO D0
#define PIN_BTN_Y D1 // Yellow (Left)
#define PIN_BTN_R D2 // Red (Center)
#define PIN_BTN_B D3 // Blue (Right)
// --- OBJECTS ---
Servo myHand;
SSCMA AI;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
VL53L1X tof;
Preferences prefs;
// --- MODES ---
enum Mode { CALIBRATION, MANUAL, TIMER, AUTO };
Mode currentMode = MANUAL;
// --- STATE ---
bool tofFound = false;
bool oledFound = false;
bool aiFound = false;
// Global Settings
int servoUpAngle = 0;
int servoDownAngle = 115;
int deskMinHeight = 720;
int deskMaxHeight = 1150;
int aiFaceSize = 2000;
int aiCenterY = 100;
int aiConfidence = 60;
// Edit Temp Variables (For Safe Calibration)
int editVal1 = 0;
bool stepDirty = false;
// Timer Settings
int timerSitMins = 30;
int timerStandMins = 15;
int timerSitHeight = 0;
int timerStandHeight = 0;
// Runtime
bool isActive = false;
bool isEditing = false;
int editStep = 0;
int currentHeightMM = 0;
// Timer Runtime
unsigned long deskTimerStart = 0;
bool isStandingPhase = true;
// Auto Runtime
bool autoReverse = false;
int curFaceX = 0;
int curFaceY = 0;
int curFaceSize = 0;
int curConf = 0;
bool validFace = false;
unsigned long lastAutoToggle = 0;
// Input State
unsigned long lastDebounce = 0;
bool lastY = HIGH;
bool lastR = HIGH;
bool lastB = HIGH;
// --- PROTOTYPES ---
void handleInputs();
void readSensors();
void readAISensor();
void updateDisplay();
void runTimerLogic();
void runAutoLogic();
void calibrationLogic(int dir);
void timerEditLogic(int dir);
void loadEditStepValue();
void nextEditStep();
void setServo(int angle);
void stopServo();
void drawCentered(String text, int y, int size);
void drawScroller(String text, int y, int size);
void bootLog(String msg);
void resetI2CBus() {
pinMode(D4, OUTPUT); pinMode(D5, OUTPUT);
digitalWrite(D4, HIGH); digitalWrite(D5, HIGH); delay(10);
for(int i=0; i<9; i++) {
digitalWrite(D5, LOW); delayMicroseconds(10);
digitalWrite(D5, HIGH); delayMicroseconds(10);
}
digitalWrite(D4, LOW); delayMicroseconds(10);
digitalWrite(D5, LOW); delayMicroseconds(10);
digitalWrite(D5, HIGH); delayMicroseconds(10);
digitalWrite(D4, HIGH); delayMicroseconds(10);
}
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("--- SMART DESK v54 ---");
pinMode(PIN_BTN_Y, INPUT_PULLUP);
pinMode(PIN_BTN_R, INPUT_PULLUP);
pinMode(PIN_BTN_B, INPUT_PULLUP);
resetI2CBus();
Wire.begin();
Wire.setClock(400000);
Wire.setTimeOut(50); // PREVENTS FREEZING
oledFound = display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
if(oledFound) {
display.clearDisplay();
display.setTextColor(WHITE);
display.setTextWrap(false);
bootLog("OLED OK");
}
tof.setTimeout(500);
if (!tof.init()) {
tofFound = false;
bootLog("TOF FAIL!");
} else {
tofFound = true;
tof.setDistanceMode(VL53L1X::Long);
tof.setMeasurementTimingBudget(50000);
tof.startContinuous(50);
bootLog("TOF OK");
}
if(AI.begin()) {
aiFound = true;
bootLog("AI OK");
} else {
aiFound = false;
bootLog("AI FAIL!");
}
bootLog("LOAD DATA...");
prefs.begin("desk-v38", false);
servoUpAngle = prefs.getInt("s_up", 0);
servoDownAngle = prefs.getInt("s_down", 115);
deskMinHeight = prefs.getInt("h_min", 720);
deskMaxHeight = prefs.getInt("h_max", 1150);
aiFaceSize = prefs.getInt("ai_size", 2000);
aiCenterY = prefs.getInt("ai_y", 100);
aiConfidence = prefs.getInt("ai_conf", 60);
timerSitMins = prefs.getInt("t_sit", 30);
timerStandMins = prefs.getInt("t_std", 15);
timerSitHeight = prefs.getInt("t_h_sit", 0);
timerStandHeight = prefs.getInt("t_h_std", 0);
// Defaults
if(deskMaxHeight == 0) deskMaxHeight = 1150;
if(aiCenterY == 0) aiCenterY = 100;
if(aiFaceSize == 0) aiFaceSize = 2000;
ESP32PWM::allocateTimer(0);
myHand.setPeriodHertz(50);
myHand.attach(PIN_SERVO, 500, 2400);
stopServo();
bootLog("READY!");
delay(500);
}
void bootLog(String msg) {
if(oledFound) {
display.clearDisplay();
drawCentered("BOOTING", 0, 1);
drawCentered(msg, 32, 2);
display.display();
}
}
void loop() {
handleInputs();
readSensors();
if (currentMode == TIMER && isActive) {
runTimerLogic();
}
// AUTO MODE LOGIC
if (currentMode == AUTO) {
readAISensor();
handleInputs(); // Anti-Block Check
if (currentMode == AUTO) runAutoLogic();
} else {
if(currentMode != TIMER && currentMode != MANUAL) isActive = false;
}
// SAFETY STOPS
if (currentMode == MANUAL && isActive) {
if (digitalRead(PIN_BTN_Y) && digitalRead(PIN_BTN_B)) stopServo();
}
if (isEditing && (editStep == 2 || editStep == 3)) {
if (digitalRead(PIN_BTN_Y) && digitalRead(PIN_BTN_B)) stopServo();
}
static unsigned long lastDisplay = 0;
if (millis() - lastDisplay > 30) {
updateDisplay();
lastDisplay = millis();
}
}
// ==========================================
// INPUTS
// ==========================================
void handleInputs() {
bool currY = digitalRead(PIN_BTN_Y);
bool currR = digitalRead(PIN_BTN_R);
bool currB = digitalRead(PIN_BTN_B);
unsigned long now = millis();
if (now - lastDebounce > 50) {
// RED CLICK
if (lastR == HIGH && currR == LOW) {
if (isEditing) {
nextEditStep();
}
else if (currentMode == TIMER) {
if (!isActive) { isEditing = true; editStep = 0; loadEditStepValue(); }
else { isActive = false; stopServo(); }
}
else if (currentMode == AUTO) {
// Button Logic: 3s Cooldown, 2-10s Hold
unsigned long pressDuration = 0;
unsigned long pressStart = millis();
while(digitalRead(PIN_BTN_R) == LOW) {
pressDuration = millis() - pressStart;
if (pressDuration > 10000) break;
delay(10);
}
if (millis() - lastAutoToggle > 3000) {
if (pressDuration > 2000 && pressDuration < 10000) {
autoReverse = !autoReverse;
lastAutoToggle = millis();
Serial.print("AUTO REV: "); Serial.println(autoReverse);
}
}
}
else if (currentMode == MANUAL) {
isActive = !isActive; stopServo();
}
else if (currentMode == CALIBRATION) {
isEditing = true; editStep = 0; loadEditStepValue();
}
lastDebounce = now;
}
// YELLOW CLICK (Left)
if (lastY == HIGH && currY == LOW) {
if (isEditing) {
stepDirty = true;
if (currentMode==CALIBRATION) calibrationLogic(-1);
if (currentMode==TIMER) timerEditLogic(-1);
}
else if (!isActive) {
int m = (int)currentMode - 1;
if (m < 0) m = 3; currentMode = (Mode)m;
}
else if (currentMode == TIMER && isActive) {
isActive = false; stopServo();
int m = (int)currentMode - 1;
if (m < 0) m = 3; currentMode = (Mode)m;
}
lastDebounce = now;
}
// Hold Logic
if (currY == LOW) {
if (currentMode == MANUAL && isActive) setServo(servoDownAngle);
if (isEditing && (editStep==2 || editStep==3)) { setServo(servoDownAngle); stepDirty = true; }
}
// BLUE CLICK (Right)
if (lastB == HIGH && currB == LOW) {
if (isEditing) {
stepDirty = true;
if (currentMode==CALIBRATION) calibrationLogic(1);
if (currentMode==TIMER) timerEditLogic(1);
}
else if (!isActive) {
int m = (int)currentMode + 1;
if (m > 3) m = 0; currentMode = (Mode)m;
}
else if (currentMode == TIMER && isActive) {
isActive = false; stopServo();
int m = (int)currentMode + 1;
if (m > 3) m = 0; currentMode = (Mode)m;
}
lastDebounce = now;
}
// Hold Logic
if (currB == LOW) {
if (currentMode == MANUAL && isActive) setServo(servoUpAngle);
if (isEditing && (editStep==2 || editStep==3)) { setServo(servoUpAngle); stepDirty = true; }
}
}
lastY = currY; lastR = currR; lastB = currB;
}
// ==========================================
// LOGIC
// ==========================================
void loadEditStepValue() {
stepDirty = false;
if (currentMode == CALIBRATION) {
if (editStep==0) editVal1 = servoUpAngle;
if (editStep==1) editVal1 = servoDownAngle;
if (editStep==4) editVal1 = aiFaceSize;
if (editStep==5) editVal1 = aiCenterY;
if (editStep==6) editVal1 = aiConfidence;
} else if (currentMode == TIMER) {
if (editStep==0) editVal1 = timerSitMins;
if (editStep==1) editVal1 = timerStandMins;
}
}
void calibrationLogic(int dir) {
if (editStep == 0) { editVal1 += (dir * 5); if(editVal1<0) editVal1=0; setServo(editVal1); }
if (editStep == 1) { editVal1 += (dir * 5); if(editVal1<0) editVal1=0; setServo(editVal1); }
if (editStep == 4) editVal1 += (dir * 100);
if (editStep == 5) editVal1 += (dir * 5);
if (editStep == 6) editVal1 += (dir * 5);
}
void timerEditLogic(int dir) {
if (editStep == 0) { editVal1 += (dir * 5); if(editVal1 < 5) editVal1 = 5; }
if (editStep == 1) { editVal1 += (dir * 5); if(editVal1 < 5) editVal1 = 5; }
}
void nextEditStep() {
stopServo();
if (currentMode == CALIBRATION) {
if (stepDirty) {
if (editStep==0) servoUpAngle = editVal1;
if (editStep==1) servoDownAngle = editVal1;
if (editStep==2 && tofFound) deskMinHeight = currentHeightMM;
if (editStep==3 && tofFound) deskMaxHeight = currentHeightMM;
if (editStep==4) aiFaceSize = editVal1;
if (editStep==5) aiCenterY = editVal1;
if (editStep==6) aiConfidence = editVal1;
}
editStep++;
if (editStep > 6) {
prefs.putInt("s_up", servoUpAngle); prefs.putInt("s_down", servoDownAngle);
prefs.putInt("h_min", deskMinHeight); prefs.putInt("h_max", deskMaxHeight);
prefs.putInt("ai_size", aiFaceSize); prefs.putInt("ai_y", aiCenterY); prefs.putInt("ai_conf", aiConfidence);
isEditing = false;
Serial.println("[CALIB] SAVED");
} else { loadEditStepValue(); }
}
else if (currentMode == TIMER) {
if (stepDirty) {
if (editStep==0) timerSitMins = editVal1;
if (editStep==1) timerStandMins = editVal1;
if (editStep==2 && tofFound) timerSitHeight = currentHeightMM;
if (editStep==3 && tofFound) timerStandHeight = currentHeightMM;
}
editStep++;
if (editStep > 3) {
prefs.putInt("t_sit", timerSitMins); prefs.putInt("t_std", timerStandMins);
prefs.putInt("t_h_sit", timerSitHeight); prefs.putInt("t_h_std", timerStandHeight);
isEditing = false; isActive = true;
deskTimerStart = millis(); isStandingPhase = true;
Serial.println("[TIMER] STARTED");
} else { loadEditStepValue(); }
}
}
void runTimerLogic() {
unsigned long elapsedSecs = (millis() - deskTimerStart) / 1000;
unsigned long limitMins = isStandingPhase ? timerStandMins : timerSitMins;
if (elapsedSecs >= (limitMins * 60)) {
isStandingPhase = !isStandingPhase;
deskTimerStart = millis();
}
int target = isStandingPhase ? timerStandHeight : timerSitHeight;
if(target == 0) target = isStandingPhase ? deskMaxHeight : deskMinHeight;
bool nearTarget = abs(currentHeightMM - target) < 20;
if (!nearTarget && tofFound) {
if (currentHeightMM < target) setServo(servoUpAngle);
else if (currentHeightMM > target) setServo(servoDownAngle);
} else { stopServo(); }
}
void runAutoLogic() {
if (!validFace) { stopServo(); return; }
int deadband = 15;
bool moveUp = false;
bool moveDown = false;
// LOGIC
if (curFaceY < aiCenterY - deadband) moveUp = true;
if (curFaceY > aiCenterY + deadband) moveDown = true;
if (autoReverse) { bool t=moveUp; moveUp=moveDown; moveDown=t; }
// LIMITS
if (tofFound && currentHeightMM > 0) {
if (currentHeightMM >= deskMaxHeight && moveUp) {
moveUp = false; Serial.print("[LIMIT] Max: "); Serial.println(currentHeightMM);
}
if (currentHeightMM <= deskMinHeight && moveDown) {
moveDown = false; Serial.print("[LIMIT] Min: "); Serial.println(currentHeightMM);
}
}
// DEBUG
Serial.print("Tgt:"); Serial.print(aiCenterY);
Serial.print(" Act:"); Serial.print(curFaceY);
if(moveUp) { Serial.println(" -> UP"); setServo(servoUpAngle); }
else if(moveDown) { Serial.println(" -> DOWN"); setServo(servoDownAngle); }
else { Serial.println(" -> HOLD"); stopServo(); }
}
void setServo(int angle) { myHand.write(angle); }
void stopServo() { myHand.write(45); }
// ==========================================
// SENSORS & DISPLAY
// ==========================================
void readSensors() {
if (tofFound) {
int r = tof.read();
if (r > 0 && r < 3000) currentHeightMM = r;
}
}
void readAISensor() {
if (!aiFound) return;
if (!AI.invoke(1, false, false)) {
if (AI.boxes().size() > 0) {
curFaceY = AI.boxes()[0].y;
curFaceX = AI.boxes()[0].x;
curFaceSize = AI.boxes()[0].w * AI.boxes()[0].h;
curConf = AI.boxes()[0].score;
if (curConf >= aiConfidence && curFaceSize >= aiFaceSize) {
validFace = true;
} else validFace = false;
} else validFace = false;
}
}
void drawCentered(String text, int y, int size) {
display.setTextSize(size);
int charWidth = (size == 1) ? 6 : 12;
if(size==3) charWidth = 18;
int textWidth = text.length() * charWidth;
int x = (128 - textWidth) / 2;
if(x < 0) x = 0;
display.setCursor(x, y);
display.print(text);
}
void drawScroller(String text, int y, int size) {
display.setTextSize(size);
int charWidth = (size == 1) ? 6 : 12;
int textWidth = text.length() * charWidth;
if (textWidth <= 128) {
display.setCursor((128 - textWidth) / 2, y);
display.print(text);
} else {
int pos = (millis() / 20) % (textWidth + 128);
int x = 128 - pos;
display.setCursor(x, y);
display.print(text);
}
}
void updateDisplay() {
if(!oledFound) return;
display.clearDisplay();
// HEADER (Restored from v38)
if (isEditing) {
if (currentMode==CALIBRATION) {
display.setTextSize(1); display.setCursor(0,0);
display.print("STEP "); display.print(editStep+1); display.print("/7");
} else {
drawCentered("EDIT TIMER", 0, 2);
}
} else {
String title = "MANUAL";
if (currentMode == CALIBRATION) title = "CALIBRATE";
if (currentMode == TIMER) title = "TIMER LOOP";
if (currentMode == AUTO) title = "FACE TRACK"; // Added Auto
drawCentered(title, 0, 2);
}
// CONTENT
float hInch = currentHeightMM / 25.4;
if (isEditing) {
String l="", v="";
if (currentMode == CALIBRATION) {
if(editStep==0) { l="TUNE UP"; v=String(servoUpAngle); }
if(editStep==1) { l="TUNE DOWN"; v=String(servoDownAngle); }
if(editStep==2) { l="SET MIN"; v=String(hInch,1)+"\""; }
if(editStep==3) { l="SET MAX"; v=String(hInch,1)+"\""; }
if(editStep==4) { l="MIN FACE"; v=String(aiFaceSize); }
if(editStep==5) { l="CENTER Y"; v=String(aiCenterY); }
if(editStep==6) { l="MIN CONF"; v=String(aiConfidence)+"%"; }
// If dirty, show edit value, else show saved value
if(stepDirty) v=String(editVal1);
} else if (currentMode == TIMER) {
if(editStep==0) { l="SIT TIME"; v=String(timerSitMins)+"m"; }
if(editStep==1) { l="STAND TIME"; v=String(timerStandMins)+"m"; }
if(editStep==2) { l="SIT HEIGHT"; v=String(hInch,1)+"\""; }
if(editStep==3) { l="STAND HEIGHT"; v=String(hInch,1)+"\""; }
if(stepDirty && editStep < 2) v=String(editVal1)+"m";
}
if(editStep > 1 && currentMode == TIMER) drawScroller(l, 16, 2);
else drawCentered(l, 16, 2);
drawCentered(v, 34, 2);
}
else if (currentMode == MANUAL) {
if (isActive) {
drawCentered(String(hInch, 1) + "\"", 20, 2);
drawScroller("PRESS RED TO EXIT", 40, 1);
if(!digitalRead(PIN_BTN_Y)) { display.setCursor(0,25); display.setTextSize(2); display.print("v"); }
if(!digitalRead(PIN_BTN_B)) { display.setCursor(115,25); display.setTextSize(2); display.print("^"); }
} else {
drawScroller("PRESS RED BUTTON TO ACTIVATE", 40, 1);
}
}
else if (currentMode == TIMER) {
if (isActive) {
String status = isStandingPhase ? "STANDING" : "SITTING";
unsigned long elapsedSecs = (millis() - deskTimerStart) / 1000;
unsigned long limitMins = isStandingPhase ? timerStandMins : timerSitMins;
long remaining = (limitMins * 60) - elapsedSecs;
if (remaining < 0) remaining = 0;
int remM = remaining / 60; int remS = remaining % 60;
String tStr = String(remM) + ":" + (remS < 10 ? "0" : "") + String(remS);
drawCentered(status, 18, 1); drawCentered(tStr, 32, 3);
} else {
drawScroller("PRESS RED BUTTON TO START", 40, 1);
}
}
else if (currentMode == AUTO) {
display.setTextSize(1);
display.setCursor(0, 20);
display.print("MODE:"); display.print(autoReverse ? "REVERSE" : "NORMAL");
display.print(" X:"); display.print(curFaceX);
display.setCursor(0, 30);
display.print("Y:"); display.print(curFaceY);
display.print(" SIZE:"); display.print(curFaceSize);
if (validFace) drawCentered("TRACKING", 48, 2);
else drawCentered("SEARCHING", 48, 2);
}
else if (currentMode == CALIBRATION) {
drawScroller("PRESS RED BUTTON TO START", 30, 2);
}
// BOTTOM BAR
int yPos = 56;
if (!digitalRead(PIN_BTN_Y)) display.fillRect(0, yPos, 20, 9, WHITE);
display.setTextColor(!digitalRead(PIN_BTN_Y) ? BLACK : WHITE);
display.setCursor(4, yPos+1); display.setTextSize(1); display.print("Y");
if (!digitalRead(PIN_BTN_R)) display.fillRect(54, yPos, 20, 9, WHITE);
display.setTextColor(!digitalRead(PIN_BTN_R) ? BLACK : WHITE);
display.setCursor(58, yPos+1); display.print("R");
if (!digitalRead(PIN_BTN_B)) display.fillRect(108, yPos, 20, 9, WHITE);
display.setTextColor(!digitalRead(PIN_BTN_B) ? BLACK : WHITE);
display.setCursor(112, yPos+1); display.print("B");
display.setTextColor(WHITE);
display.display();
}