E-brella

by amarsico_29 in Circuits > Gadgets

57 Views, 0 Favorites, 0 Comments

E-brella

IMG_9526.jpg
IMG_9529.jpg

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

IMG_9382.jpg
IMG_9399.jpg
IMG_9449.jpg

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

IMG_9420_(copy).jpg
IMG_9421.jpg
IMG_9484.jpg

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

Wire Everything

IMG_9451.jpg
IMG_9486.jpg
IMG_9487.jpg

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

IMG_9481.jpg
IMG_9482.jpg
IMG_9420_(copy).jpg
IMG_9421.jpg
IMG_9483.jpg

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

IMG_9171.jpg
IMG_9172.jpg
IMG_9173.jpg
IMG_9174.jpg
IMG_9175.jpg
IMG_9176.jpg
IMG_9177.jpg
IMG_9178.jpg
IMG_9179.jpg
IMG_9180.jpg
IMG_9181.jpg
IMG_9182.jpg
IMG_9495.jpg
IMG_9496.jpg

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.