From 5eece1a2b6882fb72a6cf2d76b67eebf79bd6e5b Mon Sep 17 00:00:00 2001 From: Jarrad Brown Date: Fri, 27 Feb 2026 18:37:27 -0600 Subject: [PATCH] Updated Enivroment Sensor Remote Control Code (Ardunio) --- .../EnvSensorRemoteCtrl.ino | 649 +++++++++++++++++- 1 file changed, 645 insertions(+), 4 deletions(-) diff --git a/uC/EnviromentSensor_uC/EnvSensorRemoteCtrl/EnvSensorRemoteCtrl.ino b/uC/EnviromentSensor_uC/EnvSensorRemoteCtrl/EnvSensorRemoteCtrl.ino index 95c2b6e..d61ec79 100644 --- a/uC/EnviromentSensor_uC/EnvSensorRemoteCtrl/EnvSensorRemoteCtrl.ino +++ b/uC/EnviromentSensor_uC/EnvSensorRemoteCtrl/EnvSensorRemoteCtrl.ino @@ -1,9 +1,650 @@ -void setup() { - // put your setup code here, to run once: +/* + Heltec WiFi Kit32 V3 (ESP32-S3) BLE Remote for "FLL-Umbrella-Env" + What this remote does: + - Scans + connects over BLE to your sensor (server) + - Lets you switch MODE: LIVE / SIM + - In SIM mode, lets you edit: Temp (°C), RH (%), Pressure (hPa), IAQ (auto or manual) + - Writes the same binary packets your sensor expects: + CMD 0x01: [0]=0x01, [1]=mode(0/1) + CMD 0x02: [0]=0x02, then LE fields: + temp_x100 (int16), rh_x100 (uint16), press_x10 (uint16), + optional iaq (uint16) + + Libraries to install (Arduino Library Manager): + - NimBLE-Arduino (by h2zero) + - U8g2 (by olikraus) + + Wiring assumptions (CHANGE IF NEEDED): + - OLED I2C (0.98" SSD1306/SH1106): + SDA = GPIO17 + SCL = GPIO18 + RST = GPIO21 (optional; set to -1 if your display has no reset pin) + - Encoder: + A = GPIO5 + B = GPIO6 + BTN = GPIO7 (to GND, uses internal pullup) + + Notes: + - If your Heltec board’s OLED pins differ, change OLED_SDA/OLED_SCL/OLED_RST below. + - If your encoder pins differ, change ENC_A/ENC_B/ENC_BTN below. +*/ + +#include +#include +#include + +#include + +// ===================== BLE IDs (must match your sensor code) ===================== +static const char* BLE_DEV_NAME_TARGET = "FLL-Umbrella-Env"; +static const char* UUID_SVC_CTRL = "12345678-1234-5678-1234-56789abcdef0"; +static const char* UUID_CHR_CTRL = "12345678-1234-5678-1234-56789abcdef1"; + +// ===================== OLED (I2C) pins ===================== +// Common on Heltec WiFi Kit32 V3: SDA=17, SCL=18, RST=21 (verify on your board) +static const int OLED_SDA = 17; +static const int OLED_SCL = 18; +static const int OLED_RST = 21; // set -1 if not used by your display + +// If your display is SSD1306 128x64 I2C: +U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, /* reset=*/ OLED_RST, /* clock=*/ OLED_SCL, /* data=*/ OLED_SDA); + +// ===================== Encoder pins ===================== +static const int ENC_A = 5; +static const int ENC_B = 6; +static const int ENC_BTN = 7; + +// ===================== Simple encoder + button handling ===================== +static volatile int32_t enc_delta = 0; +static volatile uint8_t enc_last = 0; + +static uint32_t btn_down_ms = 0; +static bool btn_last = true; // pullup => idle HIGH + +static inline bool readBtn() { return digitalRead(ENC_BTN) == LOW; } // pressed = LOW + +IRAM_ATTR void isrEnc() { + // 2-bit Gray decoding on A/B + uint8_t a = (uint8_t)digitalRead(ENC_A); + uint8_t b = (uint8_t)digitalRead(ENC_B); + uint8_t s = (a << 1) | b; + + // transition table (old->new) using 4-bit index + // produces +1/-1 per valid step, 0 otherwise + static const int8_t tbl[16] = { + 0, -1, +1, 0, + +1, 0, 0, -1, + -1, 0, 0, +1, + 0, +1, -1, 0 + }; + uint8_t idx = (enc_last << 2) | s; + enc_last = s; + enc_delta += tbl[idx]; +} + +// ===================== App state ===================== +enum Mode : uint8_t { MODE_LIVE = 0, MODE_SIM = 1 }; + +struct SimValues { + int16_t temp_x100 = 2500; // 25.00 C + uint16_t rh_x100 = 5000; // 50.00 % + uint16_t press_x10 = 10132; // 1013.2 hPa + uint16_t iaq = 25; // 0..500 + bool iaq_manual = false; // false => omit IAQ field, sensor computes heuristic +}; + +static Mode mode = MODE_LIVE; +static SimValues sim; + +// ===================== BLE client handles ===================== +static NimBLEAdvertisedDevice* advFound = nullptr; +static NimBLEClient* client = nullptr; +static NimBLERemoteCharacteristic* chrCtrl = nullptr; + +static bool bleConnected = false; +static bool bleBusy = false; +static int8_t bleRssi = 0; + +// ===================== UI ===================== +enum Screen { + SCR_HOME = 0, + SCR_MENU, + SCR_EDIT +}; + +static Screen screen = SCR_HOME; + +static const char* menuItems[] = { + "Connect / Reconnect", + "Mode: LIVE/SIM", + "Temp (C)", + "RH (%)", + "Press (hPa)", + "IAQ: Auto/Manual", + "IAQ Value (0..500)", + "Send SIM now", + "Disconnect" +}; +static const int MENU_COUNT = (int)(sizeof(menuItems) / sizeof(menuItems[0])); +static int menuIdx = 0; + +// Edit state +enum EditField { EF_NONE, EF_MODE, EF_TEMP, EF_RH, EF_PRESS, EF_IAQ_MANUAL, EF_IAQ }; +static EditField editField = EF_NONE; +static int32_t editValue = 0; + +// ===================== Helpers ===================== +static inline uint16_t clampU16(int32_t v, uint16_t lo, uint16_t hi) { + if (v < (int32_t)lo) return lo; + if (v > (int32_t)hi) return hi; + return (uint16_t)v; +} +static inline int16_t clampI16(int32_t v, int16_t lo, int16_t hi) { + if (v < (int32_t)lo) return lo; + if (v > (int32_t)hi) return hi; + return (int16_t)v; +} + +static void oledClear() { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_6x12_tf); +} + +static void oledDrawCentered(const char* s, int y) { + int w = u8g2.getStrWidth(s); + int x = (128 - w) / 2; + u8g2.drawStr(x < 0 ? 0 : x, y, s); +} + +// ===================== BLE write packets ===================== +static bool bleWrite(const uint8_t* data, size_t len) { + if (!bleConnected || !chrCtrl) return false; + // write without response preferred (your server supports WRITE | WRITE_NR) + return chrCtrl->writeValue(data, len, false); +} + +static bool sendMode(Mode m) { + uint8_t pkt[2] = { 0x01, (uint8_t)(m == MODE_SIM ? 1 : 0) }; + return bleWrite(pkt, sizeof(pkt)); +} + +static bool sendSim(const SimValues& v) { + uint8_t pkt[1 + 2 + 2 + 2 + 2]; // with IAQ (max) + size_t len = 0; + + pkt[len++] = 0x02; + + auto putU16LE = [&](uint16_t x) { + pkt[len++] = (uint8_t)(x & 0xFF); + pkt[len++] = (uint8_t)((x >> 8) & 0xFF); + }; + auto putI16LE = [&](int16_t x) { putU16LE((uint16_t)x); }; + + putI16LE(v.temp_x100); + putU16LE(v.rh_x100); + putU16LE(v.press_x10); + + if (v.iaq_manual) { + putU16LE(v.iaq); + } + return bleWrite(pkt, len); +} + +// ===================== BLE scanning/connection ===================== +class AdvCB : public NimBLEAdvertisedDeviceCallbacks { + void onResult(NimBLEAdvertisedDevice* d) override { + // Match by name OR service UUID (either is fine) + if (d->haveName() && d->getName() == BLE_DEV_NAME_TARGET) { + advFound = d; + NimBLEDevice::getScan()->stop(); + return; + } + if (d->isAdvertisingService(NimBLEUUID(UUID_SVC_CTRL))) { + advFound = d; + NimBLEDevice::getScan()->stop(); + return; + } + } +}; + +static void bleDisconnect() { + bleBusy = true; + if (client && client->isConnected()) client->disconnect(); + chrCtrl = nullptr; + bleConnected = false; + bleBusy = false; +} + +static bool bleConnectToFound() { + if (!advFound) return false; + + bleBusy = true; + + if (!client) client = NimBLEDevice::createClient(); + + if (client->isConnected()) client->disconnect(); + + if (!client->connect(advFound)) { + bleBusy = false; + return false; + } + + NimBLERemoteService* svc = client->getService(UUID_SVC_CTRL); + if (!svc) { + client->disconnect(); + bleBusy = false; + return false; + } + + chrCtrl = svc->getCharacteristic(UUID_CHR_CTRL); + if (!chrCtrl) { + client->disconnect(); + bleBusy = false; + return false; + } + + bleConnected = true; + bleBusy = false; + + // Immediately push current mode + (if SIM) sim values so remote and sensor stay aligned + sendMode(mode); + if (mode == MODE_SIM) sendSim(sim); + + return true; +} + +static bool bleScanAndConnect(uint32_t ms = 5000) { + bleBusy = true; + advFound = nullptr; + + NimBLEScan* scan = NimBLEDevice::getScan(); + scan->setAdvertisedDeviceCallbacks(new AdvCB(), true); + scan->setActiveScan(true); + scan->setInterval(45); + scan->setWindow(30); + + scan->start(ms / 1000, false); // blocking in NimBLE-Arduino (seconds) + bleBusy = false; + + if (!advFound) return false; + return bleConnectToFound(); +} + +// ===================== UI drawing ===================== +static void drawHome() { + oledClear(); + + u8g2.setFont(u8g2_font_6x12_tf); + u8g2.drawStr(0, 12, "Env Sensor BLE Remote"); + + char line[32]; + + snprintf(line, sizeof(line), "BLE: %s", bleConnected ? "CONNECTED" : "DISCONNECTED"); + u8g2.drawStr(0, 28, line); + + snprintf(line, sizeof(line), "Mode: %s", (mode == MODE_SIM) ? "SIM" : "LIVE"); + u8g2.drawStr(0, 40, line); + + // display sim values in friendly units + float t = sim.temp_x100 / 100.0f; + float rh = sim.rh_x100 / 100.0f; + float p = sim.press_x10 / 10.0f; + + snprintf(line, sizeof(line), "T %.2fC RH %.2f%%", t, rh); + u8g2.drawStr(0, 52, line); + + snprintf(line, sizeof(line), "P %.1fhPa IAQ %s", + p, sim.iaq_manual ? String(sim.iaq).c_str() : "AUTO"); + u8g2.drawStr(0, 64, line); + + u8g2.sendBuffer(); +} + +static void drawMenu() { + oledClear(); + u8g2.drawStr(0, 12, "Menu (press=select)"); + + // Show 4 lines windowed + int top = menuIdx - 1; + if (top < 0) top = 0; + if (top > MENU_COUNT - 4) top = MENU_COUNT - 4; + if (top < 0) top = 0; + + for (int i = 0; i < 4; i++) { + int idx = top + i; + if (idx >= MENU_COUNT) break; + + int y = 28 + i * 12; + if (idx == menuIdx) { + u8g2.drawBox(0, y - 10, 128, 12); + u8g2.setDrawColor(0); + u8g2.drawStr(2, y, menuItems[idx]); + u8g2.setDrawColor(1); + } else { + u8g2.drawStr(2, y, menuItems[idx]); + } + } + + // Status footer + char s[32]; + snprintf(s, sizeof(s), "%s | %s", + bleBusy ? "BUSY" : (bleConnected ? "BLE OK" : "NO BLE"), + (mode == MODE_SIM) ? "SIM" : "LIVE"); + u8g2.drawStr(0, 64, s); + + u8g2.sendBuffer(); +} + +static void drawEdit() { + oledClear(); + u8g2.drawStr(0, 12, "Edit (rot=chg)"); + + char line1[32], line2[32], line3[32]; + + switch (editField) { + case EF_MODE: + snprintf(line1, sizeof(line1), "Mode:"); + snprintf(line2, sizeof(line2), "%s", (editValue ? "SIM" : "LIVE")); + break; + case EF_TEMP: + snprintf(line1, sizeof(line1), "Temp (C):"); + snprintf(line2, sizeof(line2), "%.2f", editValue / 100.0f); + snprintf(line3, sizeof(line3), "Step: 0.25C"); + break; + case EF_RH: + snprintf(line1, sizeof(line1), "RH (%):"); + snprintf(line2, sizeof(line2), "%.2f", editValue / 100.0f); + snprintf(line3, sizeof(line3), "Step: 0.50%%"); + break; + case EF_PRESS: + snprintf(line1, sizeof(line1), "Pressure (hPa):"); + snprintf(line2, sizeof(line2), "%.1f", editValue / 10.0f); + snprintf(line3, sizeof(line3), "Step: 0.5hPa"); + break; + case EF_IAQ_MANUAL: + snprintf(line1, sizeof(line1), "IAQ Mode:"); + snprintf(line2, sizeof(line2), "%s", (editValue ? "MANUAL" : "AUTO")); + break; + case EF_IAQ: + snprintf(line1, sizeof(line1), "IAQ (0..500):"); + snprintf(line2, sizeof(line2), "%ld", (long)editValue); + snprintf(line3, sizeof(line3), "Step: 5"); + break; + default: + snprintf(line1, sizeof(line1), "Unknown edit"); + break; + } + + u8g2.drawStr(0, 28, line1); + u8g2.setFont(u8g2_font_9x15_tf); + u8g2.drawStr(0, 48, line2); + u8g2.setFont(u8g2_font_6x12_tf); + if (line3[0]) u8g2.drawStr(0, 64, line3); + + u8g2.sendBuffer(); +} + +// ===================== Button events ===================== +static bool btnShortPress = false; +static bool btnLongPress = false; + +static void pollButton() { + bool pressed = readBtn(); + uint32_t now = millis(); + + btnShortPress = false; + btnLongPress = false; + + if (pressed && btn_last == false) { + // still held + if (btn_down_ms && (now - btn_down_ms) > 600) { + // long press fire once + btnLongPress = true; + btn_down_ms = 0; // prevent repeat + } + } else if (pressed && btn_last == true) { + // just pressed + btn_down_ms = now; + } else if (!pressed && btn_last == false) { + // just released + if (btn_down_ms) { + // released before long threshold => short press + btnShortPress = true; + } + btn_down_ms = 0; + } + + btn_last = pressed ? false : true; // track "idle high" +} + +// ===================== Encoder delta ===================== +static int32_t consumeEncSteps() { + noInterrupts(); + int32_t d = enc_delta; + enc_delta = 0; + interrupts(); + // Many encoders generate 2/4 edges per detent; tune divisor if too sensitive. + // Keep it simple: divide by 2 to calm it down. + return d / 2; +} + +// ===================== Menu actions ===================== +static void enterEdit(EditField f, int32_t initial) { + editField = f; + editValue = initial; + screen = SCR_EDIT; +} + +static void applyEdit() { + // Apply editValue to the active field, clamp where needed. + switch (editField) { + case EF_MODE: + mode = (editValue ? MODE_SIM : MODE_LIVE); + if (bleConnected) sendMode(mode); + // If switching into SIM, push current sim immediately + if (bleConnected && mode == MODE_SIM) sendSim(sim); + break; + + case EF_TEMP: + sim.temp_x100 = clampI16(editValue, (int16_t)-4000, (int16_t)8500); // -40.00..85.00 + if (bleConnected && mode == MODE_SIM) sendSim(sim); + break; + + case EF_RH: + sim.rh_x100 = clampU16(editValue, 0, 10000); // 0..100.00% + if (bleConnected && mode == MODE_SIM) sendSim(sim); + break; + + case EF_PRESS: + sim.press_x10 = clampU16(editValue, 8000, 12000); // 800.0..1200.0 hPa + if (bleConnected && mode == MODE_SIM) sendSim(sim); + break; + + case EF_IAQ_MANUAL: + sim.iaq_manual = (editValue != 0); + if (bleConnected && mode == MODE_SIM) sendSim(sim); // will include/omit IAQ field + break; + + case EF_IAQ: + sim.iaq = clampU16(editValue, 0, 500); + if (bleConnected && mode == MODE_SIM && sim.iaq_manual) sendSim(sim); + break; + + default: + break; + } + + screen = SCR_MENU; + editField = EF_NONE; +} + +static void doMenuSelect() { + switch (menuIdx) { + case 0: // Connect + bleScanAndConnect(6000); + break; + + case 1: // Mode + enterEdit(EF_MODE, (mode == MODE_SIM) ? 1 : 0); + break; + + case 2: // Temp + enterEdit(EF_TEMP, sim.temp_x100); + break; + + case 3: // RH + enterEdit(EF_RH, sim.rh_x100); + break; + + case 4: // Pressure + enterEdit(EF_PRESS, sim.press_x10); + break; + + case 5: // IAQ Auto/Manual + enterEdit(EF_IAQ_MANUAL, sim.iaq_manual ? 1 : 0); + break; + + case 6: // IAQ value + enterEdit(EF_IAQ, sim.iaq); + break; + + case 7: // Send SIM now + if (bleConnected) { + // force SIM mode on device if you're in SIM locally + sendMode(mode); + if (mode == MODE_SIM) sendSim(sim); + } + break; + + case 8: // Disconnect + bleDisconnect(); + break; + } +} + +// ===================== Setup / Loop ===================== +void setup() { + Serial.begin(115200); + delay(200); + + // Encoder pins + pinMode(ENC_A, INPUT_PULLUP); + pinMode(ENC_B, INPUT_PULLUP); + pinMode(ENC_BTN, INPUT_PULLUP); + + // Seed encoder state + attach interrupts + enc_last = ((uint8_t)digitalRead(ENC_A) << 1) | (uint8_t)digitalRead(ENC_B); + attachInterrupt(digitalPinToInterrupt(ENC_A), isrEnc, CHANGE); + attachInterrupt(digitalPinToInterrupt(ENC_B), isrEnc, CHANGE); + + // OLED + u8g2.begin(); + u8g2.setContrast(255); + + // BLE init + NimBLEDevice::init("Env-Remote"); + NimBLEDevice::setPower(ESP_PWR_LVL_P6); + + screen = SCR_HOME; + + oledClear(); + oledDrawCentered("BLE Env Remote", 28); + oledDrawCentered("Press to open menu", 48); + u8g2.sendBuffer(); + delay(800); } void loop() { - // put your main code here, to run repeatedly: + pollButton(); + int32_t steps = consumeEncSteps(); -} + // (optional) RSSI if connected + if (bleConnected && client && client->isConnected()) { + int r = client->getRssi(); + if (r != 0) bleRssi = (int8_t)r; + } else { + bleConnected = false; + } + + // Navigation logic + if (screen == SCR_HOME) { + if (btnShortPress) screen = SCR_MENU; + if (steps != 0) screen = SCR_MENU; + drawHome(); + delay(50); + return; + } + + if (screen == SCR_MENU) { + if (steps != 0) { + menuIdx += (steps > 0) ? 1 : -1; + if (menuIdx < 0) menuIdx = 0; + if (menuIdx >= MENU_COUNT) menuIdx = MENU_COUNT - 1; + } + + if (btnShortPress) { + doMenuSelect(); + } + + if (btnLongPress) { + screen = SCR_HOME; + } + + drawMenu(); + delay(30); + return; + } + + if (screen == SCR_EDIT) { + // Change edit value based on field + if (steps != 0) { + switch (editField) { + case EF_MODE: + case EF_IAQ_MANUAL: + editValue = (editValue == 0) ? 1 : 0; + break; + + case EF_TEMP: { + // step 0.25C => 25 in x100 + editValue += (steps > 0 ? 25 : -25); + editValue = clampI16(editValue, -4000, 8500); + } break; + + case EF_RH: { + // step 0.50% => 50 in x100 + editValue += (steps > 0 ? 50 : -50); + editValue = clampU16(editValue, 0, 10000); + } break; + + case EF_PRESS: { + // step 0.5hPa => 5 in x10 + editValue += (steps > 0 ? 5 : -5); + editValue = clampU16(editValue, 8000, 12000); + } break; + + case EF_IAQ: { + // step 5 + editValue += (steps > 0 ? 5 : -5); + editValue = clampU16(editValue, 0, 500); + } break; + + default: + break; + } + } + + if (btnShortPress) { + applyEdit(); // accept + } + if (btnLongPress) { + // cancel + screen = SCR_MENU; + editField = EF_NONE; + } + + drawEdit(); + delay(30); + return; + } +} \ No newline at end of file