cyd-dashboard: add CYD plug dashboard sketch
4-button UI for zigbee2mqtt smart plugs with party-lock toggle, touch trace animation, and DJ Booth double-tap protection.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// TFT_eSPI User_Setup for ESP32-32E-4" (Hosyond 320x480)
|
||||
// This file is copied over vendor/esp32-32e-4/TFT_eSPI/User_Setup.h
|
||||
// by the install-libs-32e-4 task.
|
||||
// ══════════════════════════════════════════════════════════
|
||||
|
||||
#define USER_SETUP_ID 201
|
||||
|
||||
// ── Driver ──
|
||||
#define ST7796_DRIVER
|
||||
|
||||
// ── Resolution ──
|
||||
#define TFT_WIDTH 320
|
||||
#define TFT_HEIGHT 480
|
||||
|
||||
// ── SPI Pins (from lcdwiki.com/4.0inch_ESP32-32E_Display) ──
|
||||
#define TFT_MISO 12
|
||||
#define TFT_MOSI 13
|
||||
#define TFT_SCLK 14
|
||||
#define TFT_CS 15
|
||||
#define TFT_DC 2
|
||||
#define TFT_RST -1 // tied to EN, handled by board
|
||||
|
||||
// ── Backlight ──
|
||||
#define TFT_BL 27
|
||||
#define TFT_BACKLIGHT_ON HIGH
|
||||
|
||||
// ── Touch (XPT2046 resistive) ──
|
||||
#define TOUCH_CS 33
|
||||
#define TOUCH_IRQ 36
|
||||
|
||||
// ── SPI speed ──
|
||||
#define SPI_FREQUENCY 40000000
|
||||
#define SPI_READ_FREQUENCY 20000000
|
||||
#define SPI_TOUCH_FREQUENCY 2500000
|
||||
|
||||
// ── Misc ──
|
||||
#define LOAD_GLCD
|
||||
#define LOAD_FONT2
|
||||
#define LOAD_FONT4
|
||||
#define LOAD_FONT6
|
||||
#define LOAD_FONT7
|
||||
#define LOAD_FONT8
|
||||
#define LOAD_GFXFF
|
||||
#define SMOOTH_FONT
|
||||
@@ -0,0 +1,586 @@
|
||||
// CYD Plug Dashboard — 4-button UI for zigbee2mqtt smart plugs
|
||||
//
|
||||
// Hardware: Hosyond ESP32-32E 4" (320x480, ST7796, XPT2046 touch)
|
||||
// TFT pins: User_Setup.h (vendored from klubhaus-doorbell)
|
||||
// Backlight: GPIO 27
|
||||
// Touch CS: GPIO 33, IRQ: GPIO 36
|
||||
//
|
||||
// MQTT broker: 192.168.81.147:1883 (set in secrets.h)
|
||||
// Subscribe: zigbee2mqtt/<plug> (state JSON {"state":"ON"})
|
||||
// Publish: zigbee2mqtt/<plug>/set (TOGGLE)
|
||||
//
|
||||
// Layout: 2x2 grid of buttons, status bar at top
|
||||
// ┌─────────────────────────────┐
|
||||
// │ WiFi✓ MQTT✓ Squiggle● ON │
|
||||
// ├──────────────┬──────────────┤
|
||||
// │ Squiggle │ Sideboard │
|
||||
// │ ON │ OFF │
|
||||
// ├──────────────┼──────────────┤
|
||||
// │ Bamboo Lights│ DJ Booth │
|
||||
// │ ON │ OFF │
|
||||
// └──────────────┴──────────────┘
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <TFT_eSPI.h>
|
||||
#include <WiFi.h>
|
||||
#include <PubSubClient.h>
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_random.h>
|
||||
#include "secrets.h"
|
||||
|
||||
// ── Plugs ──────────────────────────────────────────────────────
|
||||
struct Plug {
|
||||
const char* name;
|
||||
const char* topic;
|
||||
bool state;
|
||||
};
|
||||
|
||||
static Plug plugs[] = {
|
||||
{"Squiggle", "zigbee2mqtt/Squiggle"},
|
||||
{"Sideboard", "zigbee2mqtt/Sideboard Lamp"},
|
||||
{"Bamboo", "zigbee2mqtt/Bamboo Lights"},
|
||||
{"DJ Booth", "zigbee2mqtt/DJ Booth"},
|
||||
};
|
||||
static const int PLUG_COUNT = sizeof(plugs) / sizeof(plugs[0]);
|
||||
|
||||
// ── Party Lock ─────────────────────────────────────────────────
|
||||
#define PARTY_LOCK_TOPIC "party-lock/set"
|
||||
bool partyLocked = false;
|
||||
bool partyLockPending = false;
|
||||
|
||||
// ── Double-tap ────────────────────────────────────────────────
|
||||
#define DOUBLE_TAP_MS 2000
|
||||
static int doubleTapIdx = -1;
|
||||
static uint32_t doubleTapTime = 0;
|
||||
|
||||
// ── State query ───────────────────────────────────────────────
|
||||
static int queryIdx = -1;
|
||||
static uint32_t queryAt = 0;
|
||||
|
||||
// ── Display ────────────────────────────────────────────────────
|
||||
#define PIN_LCD_BL 27
|
||||
#define TFT_ROTATION 1 // landscape (480x320)
|
||||
TFT_eSPI tft = TFT_eSPI();
|
||||
|
||||
// ── MQTT ───────────────────────────────────────────────────────
|
||||
WiFiClient wifi;
|
||||
PubSubClient mqtt(wifi);
|
||||
|
||||
String clientId;
|
||||
bool mqttConnected = false;
|
||||
bool wifiConnected = false;
|
||||
|
||||
// ── Touch ──────────────────────────────────────────────────────
|
||||
// TFT_eSPI handles touch via TOUCH_CS / TOUCH_IRQ from User_Setup.h.
|
||||
// Calibration from cyd-calibrate sketch:
|
||||
// raw_x → screen_x (linear, both axes increase rightward)
|
||||
// raw_y → screen_y (inverted — high raw_y = top of screen)
|
||||
// Range: raw_x 31..454, raw_y 32..281
|
||||
#define TOUCH_MIN_RAW_X 31
|
||||
#define TOUCH_MAX_RAW_X 454
|
||||
#define TOUCH_MIN_RAW_Y 32
|
||||
#define TOUCH_MAX_RAW_Y 281
|
||||
|
||||
// ── Layout ─────────────────────────────────────────────────────
|
||||
#define STATUS_BAR_H 28
|
||||
#define BUTTON_GAP 6
|
||||
#define BUTTON_PAD 6
|
||||
|
||||
struct Button {
|
||||
int x, y, w, h;
|
||||
int plug_index;
|
||||
};
|
||||
|
||||
Button buttons[PLUG_COUNT];
|
||||
|
||||
// Screen-off button in the upper-right corner of the status bar.
|
||||
// "OFF" at textSize(2) needs ~48px; make button 64px wide with padding.
|
||||
#define SCREEN_OFF_W 64
|
||||
#define SCREEN_OFF_H STATUS_BAR_H
|
||||
#define SCREEN_OFF_X (tft.width() - SCREEN_OFF_W - 4)
|
||||
#define SCREEN_OFF_Y 0
|
||||
|
||||
// Party lock toggle in the status bar (between MQTT and OFF).
|
||||
#define LOCK_BTN_W 64
|
||||
#define LOCK_BTN_H STATUS_BAR_H
|
||||
#define LOCK_BTN_X (SCREEN_OFF_X - LOCK_BTN_W - 4)
|
||||
#define LOCK_BTN_Y 0
|
||||
|
||||
bool displayOn = true;
|
||||
|
||||
// ── Colors ─────────────────────────────────────────────────────
|
||||
#define COL_BG 0x0000
|
||||
#define COL_STATUS_BG 0x18E3 // dark blue
|
||||
#define COL_BTN_OFF 0x2945 // dark gray
|
||||
#define COL_BTN_ON 0x07E0 // green
|
||||
#define COL_BORDER 0x4208
|
||||
#define COL_TEXT 0xFFFF
|
||||
#define COL_TEXT_DIM 0xAD55
|
||||
#define COL_OK 0x07E0 // green dot
|
||||
#define COL_BAD 0xF800 // red dot
|
||||
#define COL_TRACE 0x5D5D // gray trace dot
|
||||
|
||||
// ── Forward decls ──────────────────────────────────────────────
|
||||
void connectWifi();
|
||||
void connectMqtt();
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int length);
|
||||
void publishToggle(int idx);
|
||||
void drawStatusBar();
|
||||
void drawButton(int idx);
|
||||
void drawAll();
|
||||
int buttonAt(int tx, int ty);
|
||||
void layoutButtons();
|
||||
uint32_t buttonColor(const Plug& p);
|
||||
|
||||
// ── Forward decls ──────────────────────────────────────────────
|
||||
void drawButton(int idx);
|
||||
void drawAll();
|
||||
uint32_t buttonColor(const Plug& p);
|
||||
int buttonAt(int tx, int ty);
|
||||
void pushTrace(int x, int y);
|
||||
void drawTraces();
|
||||
void pruneTraces();
|
||||
|
||||
// ── Trace particles ────────────────────────────────────────────
|
||||
#define MAX_TRACES 100
|
||||
#define TRACE_FADE_MS 1000
|
||||
|
||||
struct Trace {
|
||||
int x, y;
|
||||
uint32_t time;
|
||||
};
|
||||
|
||||
static Trace traces[MAX_TRACES];
|
||||
static int traceHead = 0;
|
||||
static int traceCount = 0;
|
||||
|
||||
static uint32_t lastTraceDraw = 0;
|
||||
|
||||
// ── Setup ──────────────────────────────────────────────────────
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
delay(200);
|
||||
Serial.println();
|
||||
Serial.println("=== cyd-dashboard ===");
|
||||
|
||||
// Backlight on
|
||||
pinMode(PIN_LCD_BL, OUTPUT);
|
||||
digitalWrite(PIN_LCD_BL, HIGH);
|
||||
|
||||
// TFT init
|
||||
tft.init();
|
||||
tft.setRotation(TFT_ROTATION);
|
||||
tft.fillScreen(COL_BG);
|
||||
|
||||
// Show "booting" message
|
||||
tft.setTextColor(COL_TEXT, COL_BG);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(10, 10);
|
||||
tft.println("booting...");
|
||||
|
||||
layoutButtons();
|
||||
|
||||
// MQTT client id with random suffix so reconnects don't collide
|
||||
uint32_t r = esp_random();
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "cyd-%08X", r);
|
||||
clientId = buf;
|
||||
mqtt.setServer(MQTT_HOST, MQTT_PORT);
|
||||
mqtt.setCallback(mqttCallback);
|
||||
mqtt.setBufferSize(512);
|
||||
|
||||
connectWifi();
|
||||
|
||||
// Touch self-test: print raw Z to confirm XPT2046 is responding
|
||||
uint16_t tz = tft.getTouchRawZ();
|
||||
Serial.printf("touch self-test: raw Z=%u (non-zero = controller detected)\n", tz);
|
||||
|
||||
drawAll();
|
||||
}
|
||||
|
||||
// ── Loop ───────────────────────────────────────────────────────
|
||||
void loop() {
|
||||
if (WiFi.status() != WL_CONNECTED) {
|
||||
wifiConnected = false;
|
||||
// (Power note: WiFi TX on marginal hubs (unpowered USB) can cause brownouts.
|
||||
// If disconnects recur every ~5s, move board to direct USB or powered hub.)
|
||||
connectWifi();
|
||||
} else {
|
||||
wifiConnected = true;
|
||||
}
|
||||
|
||||
if (wifiConnected && !mqtt.connected()) {
|
||||
connectMqtt();
|
||||
}
|
||||
mqtt.loop();
|
||||
|
||||
// Touch polling — simple debounced tap.
|
||||
// From cyd-calibrate: raw_x → screen_x (linear), raw_y → screen_y (inverted).
|
||||
// Clamp because the OFF button sits at sy=0 and user taps above the
|
||||
// calibrated max produce negative sy without clamping.
|
||||
// touchWasDown: only act on finger-DOWN edge, ignore drag/repeat.
|
||||
// lastTraceX/Y: last position where a trace dot was drawn for drag feedback.
|
||||
static bool touchWasDown = false;
|
||||
static int lastTraceX = -1, lastTraceY = -1;
|
||||
static uint32_t last_touch = 0;
|
||||
uint16_t tx, ty;
|
||||
bool nowTouching = tft.getTouch(&tx, &ty, 100);
|
||||
if (nowTouching && !touchWasDown && millis() - last_touch > 250) {
|
||||
int sx = constrain(map(tx, TOUCH_MIN_RAW_X, TOUCH_MAX_RAW_X, 0, tft.width()),
|
||||
0, tft.width());
|
||||
int sy = constrain(map(ty, TOUCH_MIN_RAW_Y, TOUCH_MAX_RAW_Y, tft.height(), 0),
|
||||
0, tft.height());
|
||||
|
||||
// Zone classification (for debug + action)
|
||||
const char* zone = "unknown";
|
||||
if (!displayOn) {
|
||||
zone = "WAKE";
|
||||
} else if (sy < STATUS_BAR_H) {
|
||||
if (sx >= SCREEN_OFF_X) zone = "OFF-btn";
|
||||
else if (sx >= LOCK_BTN_X) zone = "lock-btn";
|
||||
else zone = "status-bar";
|
||||
} else {
|
||||
int idx = buttonAt(sx, sy);
|
||||
if (idx >= 0) zone = plugs[idx].name;
|
||||
else zone = "between-buttons";
|
||||
}
|
||||
Serial.printf("touch raw=(%u,%u) screen=(%d,%d) zone=%s (off_btn=%d..%d)\n",
|
||||
tx, ty, sx, sy, zone, SCREEN_OFF_X, SCREEN_OFF_X + SCREEN_OFF_W);
|
||||
|
||||
if (!displayOn) {
|
||||
digitalWrite(PIN_LCD_BL, HIGH);
|
||||
displayOn = true;
|
||||
Serial.println("display: on (wake)");
|
||||
last_touch = millis();
|
||||
return;
|
||||
}
|
||||
|
||||
// Party lock toggle: in the status bar between MQTT and OFF.
|
||||
if (sy < STATUS_BAR_H &&
|
||||
sx >= LOCK_BTN_X && sx < LOCK_BTN_X + LOCK_BTN_W) {
|
||||
doubleTapIdx = -1;
|
||||
partyLocked = !partyLocked;
|
||||
mqtt.publish(PARTY_LOCK_TOPIC, partyLocked ? "ON" : "OFF");
|
||||
drawStatusBar();
|
||||
Serial.printf("party-lock: %s\n", partyLocked ? "ON" : "OFF");
|
||||
last_touch = millis();
|
||||
return;
|
||||
}
|
||||
|
||||
// Screen-off button: any tap in the upper-right of the status bar counts,
|
||||
// even if sy would extrapolate slightly negative without clamping.
|
||||
if (sy < STATUS_BAR_H &&
|
||||
sx >= SCREEN_OFF_X && sx < SCREEN_OFF_X + SCREEN_OFF_W) {
|
||||
digitalWrite(PIN_LCD_BL, LOW);
|
||||
displayOn = false;
|
||||
Serial.println("display: off");
|
||||
last_touch = millis();
|
||||
return;
|
||||
}
|
||||
|
||||
int idx = buttonAt(sx, sy);
|
||||
if (idx >= 0) {
|
||||
char dbg[64];
|
||||
snprintf(dbg, sizeof(dbg), "tap %s at (%d,%d) locked=%d",
|
||||
plugs[idx].name, sx, sy, partyLocked);
|
||||
mqtt.publish("cyd-debug/touch", dbg);
|
||||
if (partyLocked) {
|
||||
tft.fillRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, COL_BAD);
|
||||
tft.setTextColor(COL_TEXT, COL_BAD);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(LOCK_BTN_X + 6, 4);
|
||||
tft.print("LOCK");
|
||||
delay(150);
|
||||
drawStatusBar();
|
||||
} else if (idx == 3) {
|
||||
// DJ Booth requires double-tap
|
||||
uint32_t now = millis();
|
||||
if (doubleTapIdx == 3 && now - doubleTapTime < DOUBLE_TAP_MS) {
|
||||
doubleTapIdx = -1;
|
||||
publishToggle(idx);
|
||||
} else {
|
||||
doubleTapIdx = 3;
|
||||
doubleTapTime = now;
|
||||
drawButton(idx);
|
||||
tft.setTextColor(COL_TEXT_DIM, COL_BTN_OFF);
|
||||
tft.setTextSize(1);
|
||||
tft.setCursor(buttons[idx].x + 4, buttons[idx].y + buttons[idx].h - 14);
|
||||
tft.print("tap again");
|
||||
}
|
||||
} else {
|
||||
doubleTapIdx = -1;
|
||||
publishToggle(idx);
|
||||
}
|
||||
last_touch = millis();
|
||||
}
|
||||
touchWasDown = true;
|
||||
} else if (nowTouching && touchWasDown) {
|
||||
int sx = constrain(map(tx, TOUCH_MIN_RAW_X, TOUCH_MAX_RAW_X, 0, tft.width()), 0, tft.width());
|
||||
int sy = constrain(map(ty, TOUCH_MIN_RAW_Y, TOUCH_MAX_RAW_Y, tft.height(), 0), 0, tft.height());
|
||||
pushTrace(sx, sy);
|
||||
touchWasDown = true;
|
||||
} else if (!nowTouching) {
|
||||
touchWasDown = false;
|
||||
}
|
||||
// Trace particle upkeep: prune + draw every 30ms
|
||||
if (millis() - lastTraceDraw > 30) {
|
||||
pruneTraces();
|
||||
drawTraces();
|
||||
lastTraceDraw = millis();
|
||||
}
|
||||
|
||||
// Double-tap timeout: reset if user didn't tap again in time
|
||||
if (doubleTapIdx >= 0 && millis() - doubleTapTime >= DOUBLE_TAP_MS) {
|
||||
doubleTapIdx = -1;
|
||||
if (PLUG_COUNT > 3) drawButton(3);
|
||||
}
|
||||
|
||||
// Delayed state query via /get to bypass party-lock ACL
|
||||
if (queryIdx >= 0 && millis() >= queryAt) {
|
||||
char get_topic[64];
|
||||
snprintf(get_topic, sizeof(get_topic), "%s/get", plugs[queryIdx].topic);
|
||||
mqtt.publish(get_topic, "{\"state\":\"\"}");
|
||||
Serial.printf("queried state for %s via /get\n", plugs[queryIdx].name);
|
||||
queryIdx = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Trace particles ────────────────────────────────────────────
|
||||
int buttonAt(int tx, int ty);
|
||||
|
||||
void pushTrace(int x, int y) {
|
||||
if (buttonAt(x, y) >= 0) return;
|
||||
traces[traceHead] = {x, y, millis()};
|
||||
traceHead = (traceHead + 1) % MAX_TRACES;
|
||||
if (traceCount < MAX_TRACES) traceCount++;
|
||||
}
|
||||
|
||||
void drawTraces() {
|
||||
uint32_t now = millis();
|
||||
for (int i = 0; i < traceCount; i++) {
|
||||
int idx = (traceHead - traceCount + i + MAX_TRACES) % MAX_TRACES;
|
||||
uint32_t age = now - traces[idx].time;
|
||||
if (age > TRACE_FADE_MS) continue;
|
||||
int bright = 31 - (31 * age / TRACE_FADE_MS);
|
||||
if (bright < 0) bright = 0;
|
||||
uint16_t color = tft.color565(bright, bright, bright);
|
||||
tft.fillCircle(traces[idx].x, traces[idx].y, 3, color);
|
||||
}
|
||||
}
|
||||
|
||||
void pruneTraces() {
|
||||
uint32_t now = millis();
|
||||
int prune = 0;
|
||||
while (prune < traceCount) {
|
||||
int idx = (traceHead - traceCount + prune + MAX_TRACES) % MAX_TRACES;
|
||||
if (now - traces[idx].time <= TRACE_FADE_MS) break;
|
||||
tft.fillCircle(traces[idx].x, traces[idx].y, 3, COL_BG);
|
||||
prune++;
|
||||
}
|
||||
if (prune > 0) {
|
||||
traceCount -= prune;
|
||||
}
|
||||
}
|
||||
|
||||
// ── WiFi ───────────────────────────────────────────────────────
|
||||
void connectWifi() {
|
||||
if (WiFi.status() == WL_CONNECTED) {
|
||||
if (!wifiConnected) {
|
||||
Serial.printf("WiFi: connected to %s, IP=%s\n",
|
||||
WiFi.SSID().c_str(), WiFi.localIP().toString().c_str());
|
||||
wifiConnected = true;
|
||||
drawStatusBar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
static int ssid_idx = 0;
|
||||
static uint32_t last_attempt = 0;
|
||||
static uint32_t first_try_at = 0;
|
||||
if (millis() - last_attempt < 5000) return; // back off between attempts
|
||||
|
||||
// If we've been trying the same SSID for >15s without success, give up and move on
|
||||
if (first_try_at == 0) first_try_at = millis();
|
||||
if (millis() - first_try_at > 15000) {
|
||||
Serial.printf("WiFi: giving up on %s after 15s\n", WIFI_SSIDS[ssid_idx]);
|
||||
WiFi.disconnect();
|
||||
ssid_idx = (ssid_idx + 1) % WIFI_NETWORK_COUNT;
|
||||
first_try_at = millis();
|
||||
last_attempt = millis();
|
||||
return;
|
||||
}
|
||||
|
||||
Serial.printf("WiFi: trying %s\n", WIFI_SSIDS[ssid_idx]);
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(WIFI_SSIDS[ssid_idx], WIFI_PASSPHRASES[ssid_idx]);
|
||||
|
||||
last_attempt = millis();
|
||||
}
|
||||
|
||||
// ── MQTT ───────────────────────────────────────────────────────
|
||||
void connectMqtt() {
|
||||
if (mqtt.connected()) return;
|
||||
Serial.printf("MQTT: connecting as %s\n", clientId.c_str());
|
||||
if (mqtt.connect(clientId.c_str())) {
|
||||
Serial.println("MQTT: connected");
|
||||
mqttConnected = true;
|
||||
// Subscribe to all plug state topics
|
||||
for (int i = 0; i < PLUG_COUNT; i++) {
|
||||
mqtt.subscribe(plugs[i].topic);
|
||||
}
|
||||
mqtt.subscribe(PARTY_LOCK_TOPIC);
|
||||
mqtt.publish(PARTY_LOCK_TOPIC, partyLocked ? "ON" : "OFF");
|
||||
// Query current state from each plug via /get to bypass party-lock ACL.
|
||||
for (int i = 0; i < PLUG_COUNT; i++) {
|
||||
char get_topic[64];
|
||||
snprintf(get_topic, sizeof(get_topic), "%s/get", plugs[i].topic);
|
||||
mqtt.publish(get_topic, "{\"state\":\"\"}");
|
||||
}
|
||||
} else {
|
||||
Serial.printf("MQTT: failed rc=%d\n", mqtt.state());
|
||||
mqttConnected = false;
|
||||
}
|
||||
drawStatusBar();
|
||||
}
|
||||
|
||||
void mqttCallback(char* topic, byte* payload, unsigned int length) {
|
||||
// Party lock state
|
||||
if (strcmp(topic, PARTY_LOCK_TOPIC) == 0) {
|
||||
char s[4];
|
||||
int len = min((unsigned int)3, length);
|
||||
memcpy(s, payload, len);
|
||||
s[len] = 0;
|
||||
bool new_state = (strcmp(s, "ON") == 0);
|
||||
if (partyLocked != new_state) {
|
||||
partyLocked = new_state;
|
||||
drawStatusBar();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Match topic to a plug
|
||||
int idx = -1;
|
||||
for (int i = 0; i < PLUG_COUNT; i++) {
|
||||
if (strcmp(topic, plugs[i].topic) == 0) { idx = i; break; }
|
||||
}
|
||||
if (idx < 0) return;
|
||||
|
||||
// Parse JSON, look for "state"
|
||||
StaticJsonDocument<256> doc;
|
||||
DeserializationError err = deserializeJson(doc, payload, length);
|
||||
if (err) {
|
||||
Serial.printf("JSON parse err on %s: %s\n", topic, err.c_str());
|
||||
return;
|
||||
}
|
||||
const char* state = doc["state"] | "";
|
||||
bool new_state = (strcmp(state, "ON") == 0);
|
||||
if (plugs[idx].state != new_state) {
|
||||
Serial.printf("state update: %s -> %s\n", plugs[idx].name, state);
|
||||
plugs[idx].state = new_state;
|
||||
}
|
||||
drawButton(idx);
|
||||
}
|
||||
|
||||
void publishToggle(int idx) {
|
||||
if (!mqtt.connected()) return;
|
||||
char topic[64];
|
||||
snprintf(topic, sizeof(topic), "%s/set", plugs[idx].topic);
|
||||
mqtt.publish(topic, "{\"state\":\"TOGGLE\"}");
|
||||
Serial.printf("published TOGGLE on %s\n", topic);
|
||||
// Schedule a state query 600ms later so display converges to real state
|
||||
queryIdx = idx;
|
||||
queryAt = millis() + 600;
|
||||
}
|
||||
|
||||
// ── Drawing ────────────────────────────────────────────────────
|
||||
void layoutButtons() {
|
||||
// 2x2 grid below status bar
|
||||
int grid_top = STATUS_BAR_H + BUTTON_PAD;
|
||||
int grid_h = tft.height() - grid_top - BUTTON_PAD;
|
||||
int grid_w = tft.width() - 2 * BUTTON_PAD;
|
||||
int btn_w = (grid_w - BUTTON_GAP) / 2;
|
||||
int btn_h = (grid_h - BUTTON_GAP) / 2;
|
||||
|
||||
for (int i = 0; i < PLUG_COUNT; i++) {
|
||||
int col = i % 2;
|
||||
int row = i / 2;
|
||||
buttons[i] = {
|
||||
BUTTON_PAD + col * (btn_w + BUTTON_GAP),
|
||||
grid_top + row * (btn_h + BUTTON_GAP),
|
||||
btn_w, btn_h,
|
||||
i,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void drawStatusBar() {
|
||||
tft.fillRect(0, 0, tft.width(), STATUS_BAR_H, COL_STATUS_BG);
|
||||
tft.setTextSize(2);
|
||||
tft.setTextColor(COL_TEXT, COL_STATUS_BG);
|
||||
|
||||
// WiFi dot
|
||||
tft.fillCircle(10, STATUS_BAR_H / 2, 4, wifiConnected ? COL_OK : COL_BAD);
|
||||
tft.setCursor(20, 6);
|
||||
tft.print(WiFi.localIP());
|
||||
|
||||
// MQTT dot (left of screen-off button)
|
||||
int mqtt_x = tft.width() / 2 - 20;
|
||||
tft.fillCircle(mqtt_x, STATUS_BAR_H / 2, 4, mqttConnected ? COL_OK : COL_BAD);
|
||||
tft.setCursor(mqtt_x + 10, 6);
|
||||
tft.print("MQTT");
|
||||
|
||||
// Party lock toggle (between MQTT and OFF)
|
||||
uint32_t lock_bg = partyLocked ? COL_BAD : COL_STATUS_BG;
|
||||
tft.fillRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, lock_bg);
|
||||
tft.drawRoundRect(LOCK_BTN_X, LOCK_BTN_Y, LOCK_BTN_W, LOCK_BTN_H, 4, COL_BORDER);
|
||||
tft.setTextColor(COL_TEXT, lock_bg);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(LOCK_BTN_X + 6, 4);
|
||||
tft.print(partyLocked ? "LOCK" : "UNLK");
|
||||
|
||||
// Screen-off button (upper-right corner)
|
||||
tft.fillRoundRect(SCREEN_OFF_X, SCREEN_OFF_Y, SCREEN_OFF_W, SCREEN_OFF_H, 4, COL_BORDER);
|
||||
tft.setTextColor(COL_TEXT, COL_BORDER);
|
||||
tft.setCursor(SCREEN_OFF_X + 10, 4);
|
||||
tft.print("OFF");
|
||||
}
|
||||
|
||||
void drawButton(int idx) {
|
||||
const Plug& p = plugs[idx];
|
||||
const Button& b = buttons[idx];
|
||||
|
||||
uint32_t bg = buttonColor(p);
|
||||
tft.fillRoundRect(b.x, b.y, b.w, b.h, 8, bg);
|
||||
tft.drawRoundRect(b.x, b.y, b.w, b.h, 8, COL_BORDER);
|
||||
|
||||
// Plug name (top) — textSize(2) keeps "Bamboo Lights" within ~237px button
|
||||
tft.setTextColor(COL_TEXT, bg);
|
||||
tft.setTextSize(2);
|
||||
tft.setCursor(b.x + 12, b.y + 10);
|
||||
tft.println(p.name);
|
||||
|
||||
// State (bottom)
|
||||
tft.setTextSize(4);
|
||||
int state_y = b.y + b.h - 40;
|
||||
tft.setCursor(b.x + 12, state_y);
|
||||
tft.println(p.state ? "ON" : "OFF");
|
||||
}
|
||||
|
||||
void drawAll() {
|
||||
drawStatusBar();
|
||||
for (int i = 0; i < PLUG_COUNT; i++) drawButton(i);
|
||||
}
|
||||
|
||||
uint32_t buttonColor(const Plug& p) {
|
||||
if (p.state) return COL_BTN_ON;
|
||||
return COL_BTN_OFF;
|
||||
}
|
||||
|
||||
int buttonAt(int tx, int ty) {
|
||||
if (ty < STATUS_BAR_H) return -1;
|
||||
for (int i = 0; i < PLUG_COUNT; i++) {
|
||||
const Button& b = buttons[i];
|
||||
if (tx >= b.x && tx < b.x + b.w && ty >= b.y && ty < b.y + b.h) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
// Copy this to secrets.h and fill in real values.
|
||||
// secrets.h is gitignored.
|
||||
|
||||
// WiFi networks to try in order
|
||||
static const char* WIFI_SSIDS[] = {
|
||||
"Dobro Veče",
|
||||
};
|
||||
static const char* WIFI_PASSPHRASES[] = {
|
||||
"goodnight",
|
||||
};
|
||||
static const int WIFI_NETWORK_COUNT = 1;
|
||||
|
||||
// MQTT broker
|
||||
#define MQTT_HOST "192.168.81.147"
|
||||
#define MQTT_PORT 1883
|
||||
Reference in New Issue
Block a user