E-brella
E-brella, the umbrella that follows you. This umbrella uses gps location and sun azimuth calculation to autonomously move an umbrella canopy to make shade that follows you around. The code does calculations to calculate the location of the sun and references a gps location from a puck that you walk around with and with the azimuth and gps location, the umbrella will move to cover the spot of the gps location. In addition, the gps puck also has a light sensor which allows the umbrella to not be exact but just get the shadow in the general vicinity of the gps and fine tune by choosing a location that will give the lowest light reading on the light sensor. The display on the main housing of the umbrella shows real time information such as temperature, humidity, and local time. Follow allong this build to make your own umbrella.
Supplies
Umbrella tilt mechanism - https://tinyurl.com/bdhxx9xb
steel spacer (8mm ID x 12mm OD x 18mm Length) - https://a.co/d/02x7tTTf
LM 2596 DC to DC Buck converter 3.0v-40v to 1.5v-35v - https://a.co/d/029pLLus
(x2) 24v 16rpm DC Worm Gear Motor - https://a.co/d/0itfbTby
BTS 7960 43A H-Bridge DC Motor Driver Module - https://a.co/d/048CThpd
DC Voltage Regulator Buck Converter DC 12v-24v Step Down to 5v 20A 100W - https://a.co/d/0eW7uqzl
5x20mm 2A 250v Slow Blow Fuse - https://a.co/d/0g86ojOF
Flange Sleeve Bearings 16mm Bore x 20mm OD x 16mm Length - https://a.co/d/01uidmJ0
Panel Mount Fuse Holder 5x20mm - https://a.co/d/0660rP3A
12 Wire 2A slip ring coupler - https://a.co/d/0ejxV2pQ
QI Wireless Charger Circuit Board - https://a.co/d/07jiRZL0
1 Mod 18T Spur Gear 12mm Bore - https://a.co/d/0braP7BZ
3.2" ILI9341 TFT LCD Display Module - https://a.co/d/04opz5Yi
DC 24V 10A 240W Power Supply transformer - https://a.co/d/0bTpKRBc
ATGM336H GPS+BDS Dual Mode module - https://a.co/d/0hAHYCok
A3144 Hall Effect sensor - https://a.co/d/04Hle2Wm
WAGO 221 lever nuts - https://a.co/d/0iPLPiVb
Panel Mount USB Type-A female socket - https://a.co/d/03sZaCfQ
Assortment of metric bolts - https://a.co/d/0dk12qV8
Assortment of metric heat set inserts - https://a.co/d/02CGeFyp
Needle-Roller Thrust Bearing 1-1/2" Shaft Diameter, 2-3/16" OD - https://www.mcmaster.com/5909K41/
(x2) 0.032" Thick Washer for Needle-Roller Thrust Bearing (1.5" ID) - https://www.mcmaster.com/5909K54/
Module 1 Spur Gear with Hub 80 Teeth Bore 16mm - https://shorturl.at/R7vfi
Module 1 Spur Gear with Hub 70 Teeth Bore 12mm - https://shorturl.at/eXItL
Assortment of Perfboards - https://a.co/d/009ZKCXm
ESP-32 Dev Board - https://a.co/d/01GaNYod
Seeed Studio ESP-32 C6 - https://a.co/d/04GXWdTQ
BH1750 Light sensor - https://a.co/d/0hOJpzmf
Jst PH 2.0mm 22 awg 2 pin pitch plug connector - https://a.co/d/01Sg9sDQ
660mAh 1s LiPo Battery 3.8v JST-PH2.0 - https://a.co/d/00ViijhV
24 AWG solid core pre tinned copper wire - https://a.co/d/0esGWgDW
6 x 6mm x 5mm Momentary Tactile Tact Push Button Switch - https://shorturl.at/lgA27
Joystick Module - https://a.co/d/0gHFgLUx
3 KG PETG filament
1 1/2" OD x 0.065" Wall x 1.37" ID Carbon Steel Round Tube
2" Trade Size EMT conduit x 10ft
Tools:
3d Printer
Welder
Soldering Iron
Tweezers
Angle Grinder
Drill
Hammer
Wire Stripper
Heat set insert tool
Computer
Instruction Video
Check this link to see some instructional videos on how to make this umbrella.
https://canva.link/5p0cptbciu35qve
3d Print Parts
Below are all of the files needed to be printed. I will also have the print settings listed
Umbrella tilt mech. pin - 15% infill/2 walls/0.2mm layer height
Files starting with Puck housing - 15% infill/2 walls/0.2mm layer height
Power box organizer 1 and 2 - 15% infill/2 walls/0.2mm layer height
Main housing clamp bracket 1 and 2 - 60% infill/6 walls/0.2mm layer height
Main housing base - 25% infill/7 walls/0.2mm layer height
Main housing mounting bracket - 60% infill/7 walls/0.2mm layer height
Main housing - 25% infill/7walls/0.2mm layer height
Main housing tilt motor clamp - 30% infill/7 walls/0.2mm layer height
(2x) Main housing wireless charger cover - 10% infill/2 walls/0.2mm layer height
(2x) Pole bearings (PETG) - 70% infill/6 walls/0.2mm layer height
Slip ring mounting bracket - 25% infill/3 walls/0.2mm layer height
Gear - 80% infill/8 walls/ 0.2mm layer height
Tilt and spin motor bracket - 30% infill/6 walls/0.2 mm layer height
Tilt mech. spacer - 30% infill/7 walls/0.2mm layer height
Gear spacer - 100% infill/0.2mm layer height
Downloads
Wire Everything
Above are all of the drawings to wire up the electronics. I'll also be including a video that walks through the entire project if there are any questions. Also feel free to leave comments if you have any questions and I can try my best to help.
Code
Main housing code:
// ============================================================
// E-Brella - Combined Main Controller Sketch
//
// - Home page: "E-Brella" + live clock
// - Weather page: current conditions via OpenWeatherMap
// - Manual Control page: joystick jogs the tilt motor
// - Background: continuously calculates sun position and the
// target spin/tilt angles needed to shade the puck, printed
// to Serial. NOT yet wired to auto-drive the motor - that
// comes once the hall sensor gives us real position feedback.
//
// Navigation: push joystick LEFT/RIGHT to cycle Home -> Weather
// -> Manual -> Home. UP/DOWN only does anything on the Manual
// Control page (tilts the motor). Button jumps straight to Home
// from anywhere.
// ============================================================
#include <TFT_eSPI.h>
#include <SPI.h>
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <time.h>
#include <math.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <ArduinoOTA.h>
TFT_eSPI tft = TFT_eSPI();
// ---------- FILL THESE IN ----------
const char* WIFI_SSID = "Wifi Name ";
const char* WIFI_PASSWORD= "Wifi password";
const char* OWM_API_KEY = "Key here";
// Umbrella's fixed base location (set once at install)
const double POLE_LAT = 33.9617;
const double POLE_LON = -118.3531;
// ------------------------------------
// Puck's live GPS position - updated automatically once BLE connects
double puckLat = 33.96175;
double puckLon = -118.35305;
float puckLux = 0;
bool puckDataValid = false; // flips true once real BLE data comes in
// ---------- BLE client setup - must match the puck's UUIDs exactly ----------
#define SERVICE_UUID "12345678-1234-1234-1234-1234567890ab"
#define LAT_CHAR_UUID "12345678-1234-1234-1234-1234567890ac"
#define LON_CHAR_UUID "12345678-1234-1234-1234-1234567890ad"
#define LUX_CHAR_UUID "12345678-1234-1234-1234-1234567890ae"
static BLEAddress *puckAddress = nullptr;
static BLERemoteCharacteristic* pRemoteLat = nullptr;
static BLERemoteCharacteristic* pRemoteLon = nullptr;
static BLERemoteCharacteristic* pRemoteLux = nullptr;
static BLEClient* pBLEClient = nullptr;
bool bleConnected = false;
bool bleDeviceFound = false;
unsigned long lastBleReconnectAttempt = 0;
const unsigned long BLE_RECONNECT_INTERVAL_MS = 30UL * 1000UL;
// Time zone: US Pacific, automatic DST
const char* TZ_INFO = "PST8PDT,M3.2.0,M11.1.0";
const char* NTP_SERVER1 = "pool.ntp.org";
const char* NTP_SERVER2 = "time.google.com";
const char* NTP_SERVER3 = "time.nist.gov";
// Screen size (landscape)
const int SCREEN_W = 320;
const int SCREEN_H = 240;
// Joystick pins
#define VRX_PIN 34
#define VRY_PIN 35
#define SW_PIN 25
// Motor driver pins (BTS7960) - tilt motor
#define RPWM 26
#define LPWM 33
#define R_EN 13
#define L_EN 14
// Motor driver pins (BTS7960) - spin motor
#define SPIN_RPWM 27
#define SPIN_LPWM 32
#define SPIN_R_EN 5
#define SPIN_L_EN 21
// Tilt hall effect limit switches (hard safety limits)
#define TILT_MIN_PIN 22
#define TILT_MAX_PIN 36
// Joystick tuning - centers are calibrated at boot, not hardcoded,
// since cheap joysticks don't always rest at exactly the same value
int joyCenterX = 2048; // overwritten by calibrateJoystick() in setup()
int joyCenterY = 2048;
const int TILT_DEADZONE = 400;
const int MOTOR_SPEED = 140; // reduced from 200 to lower inrush current (was causing display brownout)
// Page-toggle hysteresis
const int TOGGLE_TRIGGER = 700;
const int TOGGLE_RESET = 250;
bool toggleReady = true;
// ---------- Auto-tracking (dead-reckoning position estimate) ----------
// No continuous position sensor - we estimate position by tracking how
// long the motor has driven at a known speed, "homing" against the
// tilt hall sensors periodically to correct any drift.
const double TILT_DEG_PER_SEC = 45.0 / 9.2; // from gear ratio calc - adjust once final gears are confirmed
const double SPIN_DEG_PER_SEC = 45.0 / 9.2; // same motor/ratio assumed for spin - adjust if different
double tiltPositionEstimate = 0; // degrees from fully-flat (0 = at TILT_MIN)
double spinPositionEstimate = 0; // compass bearing estimate - assumes umbrella points North at boot!
bool tiltHomed = false;
const double AUTO_TOLERANCE_DEG = 2.0; // don't bother moving for errors smaller than this
unsigned long lastAutoTiltMove = 0;
unsigned long lastAutoSpinMove = 0;
const unsigned long AUTO_MOVE_INTERVAL_MS = 500; // how often to re-evaluate and pulse-drive
enum ManualAxis { AXIS_TILT, AXIS_SPIN };
ManualAxis selectedAxis = AXIS_TILT;
bool rowToggleReady = true;
int lastSelectedAxis = -1;
String lastManualStatus = "";
// Tilt limit overrun - sensors trigger a bit early, so keep driving
// briefly past first detection rather than stopping instantly.
// Resets only once the sensor un-triggers (moved back off it),
// not just when the joystick is released, so it can't be "cheated"
// further by repeatedly tapping the stick at the limit.
const unsigned long TILT_OVERRUN_MS = 250; // adjust to taste
bool tiltMinOverrunStarted = false;
unsigned long tiltMinOverrunStart = 0;
bool tiltMinHardStop = false;
bool tiltMaxOverrunStarted = false;
unsigned long tiltMaxOverrunStart = 0;
bool tiltMaxHardStop = false;
// Mechanical constraints
const double PIVOT_HEIGHT_M = 2.0; // adjust to your actual pole height
const double MAX_TILT_DEG = 45.0; // adjust to your mechanism's real limit
const double PI_D = 3.14159265358979323846;
const double EARTH_RADIUS_M = 6371000.0;
// ---------- Pages ----------
enum Page { PAGE_HOME, PAGE_WEATHER, PAGE_MANUAL };
Page currentPage = PAGE_HOME;
// Button one-shot edge detector
bool btnDebouncedState = HIGH;
bool btnLastRawState = HIGH;
unsigned long btnLastChangeTime = 0;
const unsigned long DEBOUNCE_MS = 50;
// Home screen clock refresh
unsigned long lastClockUpdate = 0;
const unsigned long CLOCK_UPDATE_MS = 1000;
String lastDateString = "";
String lastTimeString = "";
// Weather refresh timing
unsigned long lastWeatherFetch = 0;
const unsigned long WEATHER_INTERVAL_MS = 10UL * 60UL * 1000UL;
float currentTemp = 0;
String currentCondition = "--";
int currentHumidity = 0;
bool weatherLoaded = false;
// Sun position / target angle background calculation
unsigned long lastSunCalc = 0;
const unsigned long SUN_CALC_INTERVAL_MS = 60UL * 1000UL; // every 60 seconds
double currentSunAz = 0;
double currentSunEl = 0;
double targetSpin = 0;
double targetTilt = 0;
void setup() {
Serial.begin(115200);
pinMode(SW_PIN, INPUT_PULLUP);
calibrateJoystick();
pinMode(RPWM, OUTPUT);
pinMode(LPWM, OUTPUT);
pinMode(R_EN, OUTPUT);
pinMode(L_EN, OUTPUT);
digitalWrite(R_EN, HIGH);
digitalWrite(L_EN, HIGH);
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
pinMode(SPIN_RPWM, OUTPUT);
pinMode(SPIN_LPWM, OUTPUT);
pinMode(SPIN_R_EN, OUTPUT);
pinMode(SPIN_L_EN, OUTPUT);
digitalWrite(SPIN_R_EN, HIGH);
digitalWrite(SPIN_L_EN, HIGH);
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, 0);
pinMode(TILT_MIN_PIN, INPUT_PULLUP);
pinMode(TILT_MAX_PIN, INPUT_PULLUP);
tft.init();
tft.setRotation(1); // landscape - try 3 if upside down or mirrored for your mounting
tft.fillScreen(TFT_BLACK);
connectWiFi();
// Set up wireless (OTA) code uploads - after this, future uploads
// don't need a USB cable, just select this device under
// Tools -> Port -> Network Ports in the Arduino IDE
ArduinoOTA.setHostname("E-Brella");
ArduinoOTA.onStart([]() {
Serial.println("[OTA] Update starting...");
stopBothMotors(); // safety: don't let motors run mid-update
});
ArduinoOTA.onEnd([]() {
Serial.println("\n[OTA] Update complete, rebooting...");
});
ArduinoOTA.onError([](ota_error_t error) {
Serial.printf("[OTA] Error[%u]\n", error);
});
ArduinoOTA.begin();
Serial.println("[OTA] Ready - device visible as 'E-Brella' on the network");
// Local time for display (handles PST/PDT automatically)
configTzTime(TZ_INFO, NTP_SERVER1, NTP_SERVER2, NTP_SERVER3);
drawHomePage();
// Run the sun calculation once immediately at boot
updateSunTracking();
// Start looking for the puck over BLE
BLEDevice::init("");
startBleScan();
// Home the tilt axis to a known reference position
homeTilt();
}
void loop() {
ArduinoOTA.handle();
// Button: jump to Home from anywhere, fires once per press
if (buttonPressedEdge()) {
stopBothMotors();
currentPage = PAGE_HOME;
drawHomePage();
}
// Joystick left/right: cycle pages - only when NOT on Manual Control,
// since that page uses left/right for spin instead
if (currentPage != PAGE_MANUAL) {
int pageStep = checkPageToggle();
if (pageStep != 0) {
stopBothMotors();
int next = ((int)currentPage + pageStep + 3) % 3;
currentPage = (Page)next;
if (currentPage == PAGE_HOME) drawHomePage();
else if (currentPage == PAGE_WEATHER) { fetchWeather(); drawWeatherPage(); }
else drawManualPageStatic();
}
}
// Per-page ongoing behavior
if (currentPage == PAGE_HOME) {
if (millis() - lastClockUpdate > CLOCK_UPDATE_MS) {
updateHomeClock();
lastClockUpdate = millis();
}
} else if (currentPage == PAGE_WEATHER) {
if (millis() - lastWeatherFetch > WEATHER_INTERVAL_MS) {
fetchWeather();
drawWeatherPage();
}
} else if (currentPage == PAGE_MANUAL) {
runManualControl();
}
// Auto-tracking runs whenever manual control isn't active - the
// umbrella drives itself toward the calculated target angles.
// Jumping to Manual Control (button or joystick) always overrides this.
if (currentPage != PAGE_MANUAL) {
autoTrackTilt();
autoTrackSpin();
}
// Background sun-tracking calculation, runs regardless of page
if (millis() - lastSunCalc > SUN_CALC_INTERVAL_MS) {
updateSunTracking();
}
// BLE connection handling - connect once found, reconnect if dropped
if (bleDeviceFound && !bleConnected) {
if (millis() - lastBleReconnectAttempt > BLE_RECONNECT_INTERVAL_MS) {
lastBleReconnectAttempt = millis();
connectToPuck();
}
} else if (!bleDeviceFound) {
if (millis() - lastBleReconnectAttempt > BLE_RECONNECT_INTERVAL_MS) {
lastBleReconnectAttempt = millis();
startBleScan();
}
}
delay(20);
}
// ============================================================
// Sun position + puck offset geometry (background calculation)
// ============================================================
void updateSunTracking() {
lastSunCalc = millis();
time_t now;
time(&now);
struct tm utcTime;
gmtime_r(&now, &utcTime); // sun math needs UTC, independent of display TZ
computeSunPosition(utcTime, POLE_LAT, POLE_LON, currentSunAz, currentSunEl);
computeTargetAngles(currentSunAz, currentSunEl, targetSpin, targetTilt);
char utcBuf[32];
strftime(utcBuf, sizeof(utcBuf), "%Y-%m-%d %H:%M:%S", &utcTime);
Serial.print("[Sun Tracking] UTC: ");
Serial.print(utcBuf);
Serial.print(" Sun Az: ");
Serial.print(currentSunAz, 2);
Serial.print(" El: ");
Serial.print(currentSunEl, 2);
Serial.print(" || TARGET Spin: ");
Serial.print(targetSpin, 2);
Serial.print(" Tilt: ");
Serial.print(targetTilt, 2);
Serial.print(" Puck data: ");
Serial.println(puckDataValid ? "LIVE" : "PLACEHOLDER");
if (currentSunEl < 0) {
Serial.println("[Sun Tracking] Sun below horizon - no tracking needed");
}
}
void gpsOffsetMeters(double refLat, double refLon, double lat, double lon,
double &eastOut, double &northOut) {
double refLatRad = deg2rad(refLat);
double dLat = deg2rad(lat - refLat);
double dLon = deg2rad(lon - refLon);
northOut = dLat * EARTH_RADIUS_M;
eastOut = dLon * EARTH_RADIUS_M * cos(refLatRad);
}
void computeTargetAngles(double sunAz, double sunEl, double &spinOut, double &tiltOut) {
double puckEast, puckNorth;
gpsOffsetMeters(POLE_LAT, POLE_LON, puckLat, puckLon, puckEast, puckNorth);
if (sunEl <= 1.0) {
spinOut = sunAz;
tiltOut = MAX_TILT_DEG;
return;
}
double sunAzRad = deg2rad(sunAz);
double sunElRad = deg2rad(sunEl);
double reach = PIVOT_HEIGHT_M / tan(sunElRad);
double targetEast = puckEast + reach * sin(sunAzRad);
double targetNorth = puckNorth + reach * cos(sunAzRad);
spinOut = normalize360(rad2deg(atan2(targetEast, targetNorth)));
double tilt = 90.0 - sunEl;
if (tilt < 0) tilt = 0;
if (tilt > MAX_TILT_DEG) tilt = MAX_TILT_DEG;
tiltOut = tilt;
}
void computeSunPosition(struct tm utcTime, double lat, double lon,
double &azimuthOut, double &elevationOut) {
int year = utcTime.tm_year + 1900;
int month = utcTime.tm_mon + 1;
int day = utcTime.tm_mday;
double ut = utcTime.tm_hour + utcTime.tm_min / 60.0 + utcTime.tm_sec / 3600.0;
int y = year;
int m = month;
if (m <= 2) { y -= 1; m += 12; }
int a = floor(y / 100.0);
int b = 2 - a + floor(a / 4.0);
double jd = floor(365.25 * (y + 4716)) + floor(30.6001 * (m + 1))
+ day + b - 1524.5 + ut / 24.0;
double n = jd - 2451545.0;
double L = normalize360(280.460 + 0.9856474 * n);
double g = normalize360(357.528 + 0.9856003 * n);
double gRad = deg2rad(g);
double lambda = L + 1.915 * sin(gRad) + 0.020 * sin(2 * gRad);
double lambdaRad = deg2rad(lambda);
double epsilon = 23.439 - 0.0000004 * n;
double epsilonRad = deg2rad(epsilon);
double raRad = atan2(cos(epsilonRad) * sin(lambdaRad), cos(lambdaRad));
double ra = normalize360(rad2deg(raRad));
double decRad = asin(sin(epsilonRad) * sin(lambdaRad));
double gmst = normalize360(280.46061837 + 360.98564736629 * n);
double lst = normalize360(gmst + lon);
double hDeg = normalize360(lst - ra);
if (hDeg > 180) hDeg -= 360;
double hRad = deg2rad(hDeg);
double latRad = deg2rad(lat);
double sinElev = sin(latRad) * sin(decRad) + cos(latRad) * cos(decRad) * cos(hRad);
double elevRad = asin(sinElev);
elevationOut = rad2deg(elevRad);
double cosAz = (sin(decRad) - sin(latRad) * sinElev) / (cos(latRad) * cos(elevRad));
if (cosAz > 1.0) cosAz = 1.0;
if (cosAz < -1.0) cosAz = -1.0;
double azRad = acos(cosAz);
double az = rad2deg(azRad);
if (sin(hRad) > 0) {
az = 360.0 - az;
}
azimuthOut = az;
}
double deg2rad(double deg) { return deg * PI_D / 180.0; }
double rad2deg(double rad) { return rad * 180.0 / PI_D; }
double normalize360(double deg) {
deg = fmod(deg, 360.0);
if (deg < 0) deg += 360.0;
return deg;
}
// ============================================================
// BLE client - connects to the puck and receives live data
// ============================================================
// Called whenever the puck sends a new latitude reading
static void latNotifyCallback(BLERemoteCharacteristic* pChar, uint8_t* pData,
size_t length, bool isNotify) {
char buf[16] = {0};
memcpy(buf, pData, min(length, sizeof(buf) - 1));
puckLat = atof(buf);
puckDataValid = true;
}
static void lonNotifyCallback(BLERemoteCharacteristic* pChar, uint8_t* pData,
size_t length, bool isNotify) {
char buf[16] = {0};
memcpy(buf, pData, min(length, sizeof(buf) - 1));
puckLon = atof(buf);
}
static void luxNotifyCallback(BLERemoteCharacteristic* pChar, uint8_t* pData,
size_t length, bool isNotify) {
char buf[16] = {0};
memcpy(buf, pData, min(length, sizeof(buf) - 1));
puckLux = atof(buf);
}
class MyClientCallback : public BLEClientCallbacks {
void onConnect(BLEClient* client) override {
bleConnected = true;
Serial.println("[BLE] Connected to puck");
}
void onDisconnect(BLEClient* client) override {
bleConnected = false;
Serial.println("[BLE] Disconnected from puck - will retry");
}
};
// Called for every BLE device seen during a scan
class AdvertisedDeviceCallback : public BLEAdvertisedDeviceCallbacks {
void onResult(BLEAdvertisedDevice advertisedDevice) override {
if (advertisedDevice.haveServiceUUID() &&
advertisedDevice.isAdvertisingService(BLEUUID(SERVICE_UUID))) {
Serial.println("[BLE] Found the puck - stopping scan");
BLEDevice::getScan()->stop();
puckAddress = new BLEAddress(advertisedDevice.getAddress());
bleDeviceFound = true;
}
}
};
void startBleScan() {
Serial.println("[BLE] Scanning for puck...");
BLEScan* pScan = BLEDevice::getScan();
pScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallback());
pScan->setActiveScan(true);
pScan->start(1, false); // shortened from 5s to 1s to minimize UI freeze
}
bool connectToPuck() {
Serial.println("[BLE] Connecting to puck...");
pBLEClient = BLEDevice::createClient();
pBLEClient->setClientCallbacks(new MyClientCallback());
if (!pBLEClient->connect(*puckAddress)) {
Serial.println("[BLE] Connection failed");
return false;
}
BLERemoteService* pRemoteService = pBLEClient->getService(SERVICE_UUID);
if (pRemoteService == nullptr) {
Serial.println("[BLE] Service not found on puck");
pBLEClient->disconnect();
return false;
}
pRemoteLat = pRemoteService->getCharacteristic(LAT_CHAR_UUID);
pRemoteLon = pRemoteService->getCharacteristic(LON_CHAR_UUID);
pRemoteLux = pRemoteService->getCharacteristic(LUX_CHAR_UUID);
if (pRemoteLat == nullptr || pRemoteLon == nullptr || pRemoteLux == nullptr) {
Serial.println("[BLE] One or more characteristics not found");
pBLEClient->disconnect();
return false;
}
if (pRemoteLat->canNotify()) pRemoteLat->registerForNotify(latNotifyCallback);
if (pRemoteLon->canNotify()) pRemoteLon->registerForNotify(lonNotifyCallback);
if (pRemoteLux->canNotify()) pRemoteLux->registerForNotify(luxNotifyCallback);
Serial.println("[BLE] Successfully subscribed to puck data");
return true;
}
// ============================================================
// Button / joystick input handling
// ============================================================
bool buttonPressedEdge() {
bool raw = digitalRead(SW_PIN);
if (raw != btnLastRawState) {
btnLastChangeTime = millis();
btnLastRawState = raw;
}
if ((millis() - btnLastChangeTime) > DEBOUNCE_MS) {
if (btnDebouncedState != raw) {
btnDebouncedState = raw;
if (btnDebouncedState == LOW) return true;
}
}
return false;
}
// Averages several readings at boot to find the joystick's ACTUAL
// resting center, instead of assuming it's exactly 2048. Don't touch
// the stick while this runs - keep it centered.
void calibrateJoystick() {
Serial.println("Calibrating joystick center - don't touch the stick...");
delay(300); // let things settle after power-on
long sumX = 0;
long sumY = 0;
const int samples = 50;
for (int i = 0; i < samples; i++) {
sumX += analogRead(VRX_PIN);
sumY += analogRead(VRY_PIN);
delay(10);
}
joyCenterX = sumX / samples;
joyCenterY = sumY / samples;
Serial.print("Joystick calibrated - X center: ");
Serial.print(joyCenterX);
Serial.print(" Y center: ");
Serial.println(joyCenterY);
// Sanity check - if these are wildly off from ~2048, something's
// likely wrong with the wiring rather than just normal drift
if (joyCenterX < 1000 || joyCenterX > 3000 || joyCenterY < 1000 || joyCenterY > 3000) {
Serial.println("WARNING: calibrated center is far from the expected ~2048 - check wiring");
}
}
int checkPageToggle() {
int xVal = analogRead(VRX_PIN);
int deflection = xVal - joyCenterX;
if (toggleReady) {
if (deflection > TOGGLE_TRIGGER) { toggleReady = false; return -1; }
else if (deflection < -TOGGLE_TRIGGER) { toggleReady = false; return 1; }
} else {
if (abs(deflection) < TOGGLE_RESET) toggleReady = true;
}
return 0;
}
// ============================================================
// Home page
// ============================================================
void drawHomePage() {
tft.fillScreen(TFT_BLACK);
tft.setTextSize(3);
tft.setTextColor(TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 30);
tft.println("E-Brella");
lastDateString = "";
lastTimeString = "";
updateHomeClock();
tft.setTextSize(1);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, SCREEN_H - 30);
tft.println("Push stick left/right for pages");
}
void updateHomeClock() {
struct tm timeinfo;
String dateStr = "--";
String timeStr = "Syncing...";
if (getLocalTime(&timeinfo, 1000)) {
char dateBuf[32];
char timeBuf[32];
strftime(dateBuf, sizeof(dateBuf), "%a, %b %d", &timeinfo);
strftime(timeBuf, sizeof(timeBuf), "%I:%M:%S %p", &timeinfo);
dateStr = String(dateBuf);
timeStr = String(timeBuf);
}
if (dateStr != lastDateString) {
tft.fillRect(10, 100, SCREEN_W - 20, 30, TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 100);
tft.println(dateStr);
lastDateString = dateStr;
}
if (timeStr != lastTimeString) {
tft.fillRect(10, 135, SCREEN_W - 20, 30, TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setCursor(10, 135);
tft.println(timeStr);
lastTimeString = timeStr;
}
}
// ============================================================
// WiFi
// ============================================================
void connectWiFi() {
tft.setCursor(10, 10);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.println("Connecting WiFi...");
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 30) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi connected");
Serial.print("IP address: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nWiFi failed - clock/weather/sun tracking will be affected");
}
}
// ============================================================
// Weather
// ============================================================
void fetchWeather() {
lastWeatherFetch = millis();
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Skipping weather fetch - WiFi not connected");
return;
}
HTTPClient http;
String url = "http://api.openweathermap.org/data/2.5/weather?lat=" +
String(POLE_LAT, 4) + "&lon=" + String(POLE_LON, 4) +
"&units=imperial&appid=" + String(OWM_API_KEY);
http.begin(url);
int httpCode = http.GET();
Serial.print("Weather HTTP code: ");
Serial.println(httpCode);
if (httpCode == 200) {
String payload = http.getString();
JsonDocument doc;
DeserializationError err = deserializeJson(doc, payload);
if (!err) {
currentTemp = doc["main"]["temp"];
currentHumidity = doc["main"]["humidity"];
currentCondition = doc["weather"][0]["main"].as<String>();
weatherLoaded = true;
} else {
Serial.println("JSON parse failed");
}
} else {
Serial.println(http.getString());
}
http.end();
}
void drawWeatherPage() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(10, 20);
tft.println("Weather");
tft.setTextSize(3);
tft.setCursor(10, 80);
if (weatherLoaded) {
tft.print((int)currentTemp);
tft.println(" F");
tft.setTextSize(2);
tft.setCursor(10, 130);
tft.println(currentCondition);
tft.setCursor(10, 165);
tft.print("Humidity: ");
tft.print(currentHumidity);
tft.println("%");
} else {
tft.setTextSize(2);
tft.println("No data yet");
}
tft.setTextSize(1);
tft.setCursor(10, SCREEN_H - 30);
tft.println("Push stick left/right for pages");
}
// ============================================================
// Manual control
// ============================================================
void drawManualPageStatic() {
tft.fillScreen(TFT_BLACK);
tft.setTextColor(TFT_WHITE, TFT_BLACK);
tft.setTextSize(2);
tft.setCursor(10, 20);
tft.println("Manual Control");
tft.setTextSize(1);
tft.setCursor(10, SCREEN_H - 40);
tft.println("Left/right jogs selected axis");
tft.setCursor(10, SCREEN_H - 25);
tft.println("Push down to switch. Button = home");
lastSelectedAxis = -1; // force header redraw
lastManualStatus = "";
}
void stopBothMotors() {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, 0);
}
// Ramps a motor pin up to target speed over ~150ms instead of
// jumping instantly to full PWM - reduces the current inrush spike
// that was likely causing the display brownout/noise.
void softStart(int pin, int targetSpeed) {
for (int s = 60; s <= targetSpeed; s += 20) {
analogWrite(pin, s);
delay(15);
}
analogWrite(pin, targetSpeed);
}
// Hall sensors read LOW when a magnet is detected (triggered)
bool tiltAtMin() { return digitalRead(TILT_MIN_PIN) == LOW; }
bool tiltAtMax() { return digitalRead(TILT_MAX_PIN) == LOW; }
// Drive tilt down to the MIN sensor once at boot, establishing a
// known zero reference for the dead-reckoning position estimate.
void homeTilt() {
Serial.println("[Homing] Driving tilt to MIN sensor...");
unsigned long start = millis();
const unsigned long HOMING_TIMEOUT_MS = 20000; // safety cutoff
analogWrite(RPWM, 0);
analogWrite(LPWM, MOTOR_SPEED);
while (!tiltAtMin()) {
ArduinoOTA.handle(); // stay responsive during the (blocking) homing move
if (millis() - start > HOMING_TIMEOUT_MS) {
Serial.println("[Homing] TIMEOUT - sensor never triggered, check wiring/mechanism");
break;
}
delay(10);
}
analogWrite(LPWM, 0);
tiltPositionEstimate = 0;
tiltHomed = true;
Serial.println("[Homing] Tilt homed to 0 degrees.");
}
// Drives tilt toward targetTilt using the dead-reckoning estimate,
// re-homing automatically whenever it happens to pass the MIN sensor
// (correcting any accumulated drift for free).
void autoTrackTilt() {
if (!tiltHomed) return;
double error = targetTilt - tiltPositionEstimate;
if (fabs(error) < AUTO_TOLERANCE_DEG) {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
return;
}
if (millis() - lastAutoTiltMove < AUTO_MOVE_INTERVAL_MS) return;
double elapsedSec = (millis() - lastAutoTiltMove) / 1000.0;
lastAutoTiltMove = millis();
if (error > 0) { // need more tilt - drive UP
if (tiltAtMax()) {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
return;
}
analogWrite(LPWM, 0);
analogWrite(RPWM, MOTOR_SPEED);
tiltPositionEstimate += TILT_DEG_PER_SEC * elapsedSec;
} else { // need less tilt - drive DOWN
if (tiltAtMin()) {
tiltPositionEstimate = 0; // free re-home
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
return;
}
analogWrite(RPWM, 0);
analogWrite(LPWM, MOTOR_SPEED);
tiltPositionEstimate -= TILT_DEG_PER_SEC * elapsedSec;
}
}
// Drives spin toward targetSpin using dead-reckoning only - no spin
// sensor exists, so this will drift over a session with no correction.
// ASSUMES the umbrella is physically pointed North at boot.
void autoTrackSpin() {
double error = targetSpin - spinPositionEstimate;
// Normalize to -180..180 for shortest-path rotation
while (error > 180) error -= 360;
while (error < -180) error += 360;
if (fabs(error) < AUTO_TOLERANCE_DEG) {
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, 0);
return;
}
if (millis() - lastAutoSpinMove < AUTO_MOVE_INTERVAL_MS) return;
double elapsedSec = (millis() - lastAutoSpinMove) / 1000.0;
lastAutoSpinMove = millis();
if (error > 0) { // drive RIGHT (increasing bearing) - verify this matches your wiring
analogWrite(SPIN_LPWM, 0);
analogWrite(SPIN_RPWM, MOTOR_SPEED);
spinPositionEstimate += SPIN_DEG_PER_SEC * elapsedSec;
} else {
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, MOTOR_SPEED);
spinPositionEstimate -= SPIN_DEG_PER_SEC * elapsedSec;
}
spinPositionEstimate = fmod(spinPositionEstimate + 360.0, 360.0);
}
// Row-select toggle (down only) - one-shot with hysteresis, same
// pattern as page navigation. Only cares about DOWN, never up,
// so it's immune to the up/phantom-X coupling issue entirely.
bool checkRowToggle() {
int yVal = analogRead(VRY_PIN);
int deflection = yVal - joyCenterY;
if (rowToggleReady) {
if (deflection < -TOGGLE_TRIGGER) { // down only
rowToggleReady = false;
return true;
}
} else {
if (abs(deflection) < TOGGLE_RESET) rowToggleReady = true;
}
return false;
}
void runManualControl() {
// Switch which axis is selected (down only, one-shot)
if (checkRowToggle()) {
stopBothMotors();
selectedAxis = (selectedAxis == AXIS_TILT) ? AXIS_SPIN : AXIS_TILT;
}
// Left/right always jogs whichever axis is currently selected -
// this is the one direction we've confirmed is fully reliable
int xVal = analogRead(VRX_PIN);
int xDeflection = xVal - joyCenterX;
String status;
if (selectedAxis == AXIS_TILT) {
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, 0);
if (xDeflection > TILT_DEADZONE) {
// DOWN direction
if (!tiltAtMin()) {
// Not at the sensor - clear any prior overrun state so the
// next approach gets a fresh overrun allowance
tiltMinOverrunStarted = false;
tiltMinHardStop = false;
}
if (tiltMinHardStop) {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
status = "TILT: MIN LIMIT";
} else if (tiltAtMin()) {
if (!tiltMinOverrunStarted) {
tiltMinOverrunStarted = true;
tiltMinOverrunStart = millis();
}
if (millis() - tiltMinOverrunStart < TILT_OVERRUN_MS) {
// Still within the overrun window - keep driving briefly
analogWrite(RPWM, 0);
analogWrite(LPWM, MOTOR_SPEED);
status = "TILT: DOWN (overrun)";
} else {
tiltMinHardStop = true;
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
status = "TILT: MIN LIMIT";
}
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, MOTOR_SPEED);
status = "TILT: DOWN";
}
} else if (xDeflection < -TILT_DEADZONE) {
// UP direction
if (!tiltAtMax()) {
tiltMaxOverrunStarted = false;
tiltMaxHardStop = false;
}
if (tiltMaxHardStop) {
analogWrite(LPWM, 0);
analogWrite(RPWM, 0);
status = "TILT: MAX LIMIT";
} else if (tiltAtMax()) {
if (!tiltMaxOverrunStarted) {
tiltMaxOverrunStarted = true;
tiltMaxOverrunStart = millis();
}
if (millis() - tiltMaxOverrunStart < TILT_OVERRUN_MS) {
analogWrite(LPWM, 0);
analogWrite(RPWM, MOTOR_SPEED);
status = "TILT: UP (overrun)";
} else {
tiltMaxHardStop = true;
analogWrite(LPWM, 0);
analogWrite(RPWM, 0);
status = "TILT: MAX LIMIT";
}
} else {
analogWrite(LPWM, 0);
analogWrite(RPWM, MOTOR_SPEED);
status = "TILT: UP";
}
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
status = "TILT: STOP";
}
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
if (xDeflection > TILT_DEADZONE) {
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, MOTOR_SPEED);
status = "SPIN: LEFT";
} else if (xDeflection < -TILT_DEADZONE) {
analogWrite(SPIN_LPWM, 0);
analogWrite(SPIN_RPWM, MOTOR_SPEED);
status = "SPIN: RIGHT";
} else {
analogWrite(SPIN_RPWM, 0);
analogWrite(SPIN_LPWM, 0);
status = "SPIN: STOP";
}
}
// Redraw the "which row is selected" header only when it changes,
// and NEVER while a motor is actively driving (screen writes during
// motor operation are corrupting the SPI bus)
bool motorActive = (status.indexOf("STOP") == -1) && (status.indexOf("LIMIT") == -1);
int axisNow = (int)selectedAxis;
if (axisNow != lastSelectedAxis && !motorActive) {
tft.fillRect(10, 50, SCREEN_W - 20, 30, TFT_BLACK);
tft.setTextSize(2);
tft.setTextColor(TFT_CYAN, TFT_BLACK);
tft.setCursor(10, 50);
tft.println(selectedAxis == AXIS_TILT ? "> Controlling: TILT" : "> Controlling: SPIN");
lastSelectedAxis = axisNow;
}
if (status != lastManualStatus && !motorActive) {
tft.fillRect(10, 100, SCREEN_W - 20, 40, TFT_BLACK);
tft.setTextSize(3);
tft.setTextColor(status.endsWith("STOP") ? TFT_YELLOW : TFT_GREEN, TFT_BLACK);
tft.setCursor(10, 100);
tft.println(status);
lastManualStatus = status;
}
}
Puck code:
#include <TinyGPSPlus.h>
#include <Wire.h>
#include <BH1750.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>
TinyGPSPlus gps;
BH1750 lightMeter;
#define BUTTON_PIN D3
// ---------- BLE setup ----------
// Custom UUIDs - these must match exactly on the receiving (main controller) side
#define SERVICE_UUID "12345678-1234-1234-1234-1234567890ab"
#define LAT_CHAR_UUID "12345678-1234-1234-1234-1234567890ac"
#define LON_CHAR_UUID "12345678-1234-1234-1234-1234567890ad"
#define LUX_CHAR_UUID "12345678-1234-1234-1234-1234567890ae"
BLEServer* pServer = nullptr;
BLECharacteristic* pLatChar = nullptr;
BLECharacteristic* pLonChar = nullptr;
BLECharacteristic* pLuxChar = nullptr;
bool deviceConnected = false;
const unsigned long UPDATE_INTERVAL_MS = 10UL * 1000UL; // every 10 seconds normally
unsigned long lastUpdate = 0;
// Button one-shot edge detector (same pattern as main controller)
bool btnDebouncedState = HIGH;
bool btnLastRawState = HIGH;
unsigned long btnLastChangeTime = 0;
const unsigned long DEBOUNCE_MS = 50;
class ServerCallbacks : public BLEServerCallbacks {
void onConnect(BLEServer* server) override {
deviceConnected = true;
Serial.println("Main controller connected");
}
void onDisconnect(BLEServer* server) override {
deviceConnected = false;
Serial.println("Main controller disconnected - resuming advertising");
pServer->startAdvertising(); // so the main controller can reconnect
}
};
void setup() {
Serial.begin(115200);
delay(1000);
pinMode(BUTTON_PIN, INPUT_PULLUP);
// GPS on UART (D6=TX, D7=RX)
Serial1.begin(9600, SERIAL_8N1, D7, D6);
// Light sensor on I2C (D4=SDA, D5=SCL)
Wire.begin(D4, D5);
if (lightMeter.begin(BH1750::CONTINUOUS_HIGH_RES_MODE)) {
Serial.println("BH1750 initialized");
} else {
Serial.println("BH1750 NOT FOUND");
}
setupBLE();
Serial.println("Puck ready. Waiting for GPS fix and BLE connection...");
}
void loop() {
// Feed GPS parser
while (Serial1.available() > 0) {
gps.encode(Serial1.read());
}
bool forceUpdate = buttonPressedEdge();
if (forceUpdate) {
Serial.println(">>> BUTTON PRESSED - forcing immediate update <<<");
}
if (forceUpdate || millis() - lastUpdate > UPDATE_INTERVAL_MS) {
lastUpdate = millis();
sendUpdate(forceUpdate);
}
delay(20);
}
void sendUpdate(bool forced) {
float lux = lightMeter.readLightLevel();
if (gps.location.isValid()) {
double lat = gps.location.lat();
double lon = gps.location.lng();
// Convert to strings for the BLE characteristics
char latStr[16];
char lonStr[16];
dtostrf(lat, 0, 6, latStr);
dtostrf(lon, 0, 6, lonStr);
pLatChar->setValue(latStr);
pLonChar->setValue(lonStr);
char luxStr[16];
dtostrf(lux, 0, 2, luxStr);
pLuxChar->setValue(luxStr);
if (deviceConnected) {
pLatChar->notify();
pLonChar->notify();
pLuxChar->notify();
}
Serial.print(forced ? "[FORCED] " : "");
Serial.print("Sent - Lat: ");
Serial.print(latStr);
Serial.print(" Lon: ");
Serial.print(lonStr);
Serial.print(" Lux: ");
Serial.print(luxStr);
Serial.print(" Connected: ");
Serial.println(deviceConnected ? "yes" : "no (advertising)");
} else {
Serial.println("No GPS fix yet - not sending position update");
}
}
void setupBLE() {
BLEDevice::init("E-Brella-Puck");
pServer = BLEDevice::createServer();
pServer->setCallbacks(new ServerCallbacks());
BLEService* pService = pServer->createService(SERVICE_UUID);
pLatChar = pService->createCharacteristic(
LAT_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
pLatChar->addDescriptor(new BLE2902());
pLonChar = pService->createCharacteristic(
LON_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
pLonChar->addDescriptor(new BLE2902());
pLuxChar = pService->createCharacteristic(
LUX_CHAR_UUID, BLECharacteristic::PROPERTY_READ | BLECharacteristic::PROPERTY_NOTIFY);
pLuxChar->addDescriptor(new BLE2902());
pService->start();
BLEAdvertising* pAdvertising = BLEDevice::getAdvertising();
pAdvertising->addServiceUUID(SERVICE_UUID);
pAdvertising->setScanResponse(true);
BLEDevice::startAdvertising();
Serial.println("BLE advertising started as 'E-Brella-Puck'");
}
bool buttonPressedEdge() {
bool raw = digitalRead(BUTTON_PIN);
if (raw != btnLastRawState) {
btnLastChangeTime = millis();
btnLastRawState = raw;
}
if ((millis() - btnLastChangeTime) > DEBOUNCE_MS) {
if (btnDebouncedState != raw) {
btnDebouncedState = raw;
if (btnDebouncedState == LOW) return true;
}
}
return false;
}
Heat Set Inserts
In the first to fourth picture, anywhere with a brass heat set insert will be an m3 insert. Then in the fifth picture, all the inserts seen here will be an m2. I like to use 550 degrees on my iron to put the inserts in.
Tilt Mechanism Modification
First we can modify our tilt mechanism. We need to make it move freely instead of locking. First, remove the bolt running thought the center of the tilting mechanism (Picture 1). Second, take something like a screwdriver or a tool that just barley cant fit through the hole. After hitting it a few times with a hammer the slotted spring pin should come out of the hole (Pictures 2 and 3). Once you have removed the spring pin push in the locking mechanism button, (Pictures 4 and 5) and pull the bottom piece out of the top piece. This should get you a completely disassembled mechanism with 6 total parts (Picture 6). On the push button there is a little pin on the backside that will need to be removed. Any way can be used to remove it. I simply sanded it down (Picture 7). Next, also on the push button there is a small tab that you will have to remove (Pictures 8 and 9). I used a Dremel rotary tool to remove it, and it does not have to at all be a clean cut. You just need to get that tab out of the way. After cutting the tab, 3d print the file above. All it does is hold the push button in place once we reassemble the mechanism. It will take the place of the spring (Picture 10). Finally, reassemble the entire mechanism. Just don't put the screw back on (Pictures 11 and 12). In the last two pictures, you will then drill a hole on both sides of the tilt mechanism big enough to fit a round magnet which will be used with our hall sensors.