Updated Enivroment Sensor Remote Control Code (Ardunio)

This commit is contained in:
Jarrad Brown 2026-02-27 18:37:27 -06:00
parent 517af872b7
commit 5eece1a2b6

View file

@ -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 boards 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 <Arduino.h>
#include <Wire.h>
#include <U8g2lib.h>
#include <NimBLEDevice.h>
// ===================== 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;
}
}