Compare commits
2 Commits
67bb68b097
...
5383edd4ae
| Author | SHA1 | Date | |
|---|---|---|---|
| 5383edd4ae | |||
| ad9a5b4e02 |
@@ -1 +1,2 @@
|
|||||||
output/
|
output/
|
||||||
|
secrets.h
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
# /// script
|
# /// script
|
||||||
# dependencies = [
|
# dependencies = [
|
||||||
# "paho-mqtt>=2.0",
|
# "paho-mqtt>=2.0",
|
||||||
|
# "docker>=7.0",
|
||||||
# ]
|
# ]
|
||||||
# ///
|
# ///
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import stat
|
import stat
|
||||||
|
import time
|
||||||
|
|
||||||
|
import docker
|
||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
|
MQTT_HOST = os.environ.get("MQTT_HOST", "mosquitto")
|
||||||
@@ -25,7 +28,13 @@ def rebuild_acl():
|
|||||||
f.write("\n".join(lines) + "\n")
|
f.write("\n".join(lines) + "\n")
|
||||||
os.chmod(ACL_PATH, stat.S_IRWXU)
|
os.chmod(ACL_PATH, stat.S_IRWXU)
|
||||||
os.chown(ACL_PATH, 1883, 1883)
|
os.chown(ACL_PATH, 1883, 1883)
|
||||||
os.system("docker kill -s HUP mosquitto 2>/dev/null || true")
|
try:
|
||||||
|
dc = docker.DockerClient(base_url="unix://var/run/docker.sock")
|
||||||
|
mosq = dc.containers.get("mosquitto")
|
||||||
|
mosq.kill(signal="HUP")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
def on_message(client, userdata, msg):
|
def on_message(client, userdata, msg):
|
||||||
global locked
|
global locked
|
||||||
|
|||||||
Reference in New Issue
Block a user