//Copyright <2026> <Dorian Fritze>
//Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//1. Redistributions of source code must retain the above copyright notice,
// this list of conditions and the following disclaimer.
//2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from this
// software without specific prior written permission.
//THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
// POSSIBILITY OF SUCH DAMAGE.
// ============================================================
// ESP32-C6 Teacher Node v8
// Polls all ATtiny412 nodes over I2C.
//
// Sequence nodes (0x10, 0x11, 0x12):
// - Forward (0x10→0x11→0x12): turns transmitter ON for 10 min
// or extends timer another 10 min
// - Reverse (0x12→0x11→0x10): turns transmitter OFF immediately
// - Both must complete within TIME_WINDOW (10 seconds)
// - COOLDOWN period after any completion prevents double-triggers
// - Forward times out automatically if not completed
//
// Warning node (0x16):
// - ESP32 sends 0x01 at XKT_ON_TIME - ALARM_TIME (9min 30sec)
// - ESP32 sends 0x00 on transmitter off (any reason)
// - Warning node handles buzzer + transmitter pulsing locally
//
// Read-only nodes (0x13, 0x14, 0x15, 0x17, 0x18, 0x19):
// - ESP32 reads outputOn state only
// - ATtiny manages its own lifecycle
// - No reset commands sent
//
// Serial output streams over WiFi via Telnet (port 23)
// Connect from your computer: nc <ESP_IP> 23
// ─────────────────────────────────────────────────────────
// SETUP: fill in WIFI_SSID and WIFI_PASS below.
// IP address prints to USB Serial on boot (once only).
// ============================================================
#include <Wire.h>
#include <WiFi.h>
// ────────────────────────────────────────────────────────────
// WiFi CREDENTIALS ← fill these in with the wireless name
// and password you are connected to
// ────────────────────────────────────────────────────────────
#define WIFI_SSID "Your_Wireless"
#define WIFI_PASS "Your_Password"
#define TELNET_PORT 23
WiFiServer telnetServer(TELNET_PORT);
WiFiClient telnetClient;
// ────────────────────────────────────────────────────────────
// HELPER: print to USB Serial AND Telnet client
// ────────────────────────────────────────────────────────────
void tprint(const char* msg) {
Serial.print(msg);
if (telnetClient && telnetClient.connected())
telnetClient.print(msg);
}
void tprintln(const char* msg = "") {
Serial.println(msg);
if (telnetClient && telnetClient.connected())
telnetClient.println(msg);
}
void tprintf(const char* fmt, ...) {
char buf[256];
va_list args;
va_start(args, fmt);
vsnprintf(buf, sizeof(buf), fmt, args);
va_end(args);
tprint(buf);
}
// ────────────────────────────────────────────────────────────
// PIN ASSIGNMENTS
// ────────────────────────────────────────────────────────────
#define SWITCH_PIN D8 // XKT001X via 2N3906 PNP (GPIO19)
// ────────────────────────────────────────────────────────────
// NODE ADDRESSES
// ────────────────────────────────────────────────────────────
uint8_t seqNodes[] = {0x10, 0x11, 0x12};
#define WARNING_NODE 0x16
// read-only polled nodes and their labels
uint8_t readNodes[] = {0x13, 0x14, 0x15, 0x17, 0x18, 0x19};
const char* readLabels[] = {
"cream lights", // 0x13
"yellow lights", // 0x14
"RGB red", // 0x15
"RGB green", // 0x17
"RGB blue", // 0x18
"bugs" // 0x19
};
const int NUM_SEQ = 3;
const int NUM_READ = 6;
// ────────────────────────────────────────────────────────────
// TIMING
// ────────────────────────────────────────────────────────────
#define TIME_WINDOW 10000 // ms — sequence must complete within
#define RECAST_WINDOW 30000UL // ms — forward sequence locked after activation
// reverse only between 30s and ALARM_TIME
// forward re-allowed at ALARM_TIME (9:30)
#define XKT_ON_TIME 600000UL // ms — transmitter on time (10 minutes)
#define ALARM_TIME 30000UL // ms — warning duration (must match warning node)
// ────────────────────────────────────────────────────────────
// FORWARD SEQUENCE STATE
// ────────────────────────────────────────────────────────────
int nextExpected = 0;
unsigned long firstTrigger = 0;
// ────────────────────────────────────────────────────────────
// REVERSE SEQUENCE STATE
// ────────────────────────────────────────────────────────────
int nextReverse = 2; // expects 0x12 (index 2) first
unsigned long firstReverse = 0;
unsigned long powerOffTime = 0;
#define OFF_LOCKOUT 5000UL // ms — forward locked after power off
// ────────────────────────────────────────────────────────────
// EDGE DETECTION STATE
// Tracks last sensor state for each sequence node so sequences
// only advance on rising edge (inactive → active), not while
// the magnet is held in place.
// ────────────────────────────────────────────────────────────
bool lastForwardState = false;
bool lastReverseState = false;
// ────────────────────────────────────────────────────────────
// TRANSMITTER STATE
// ────────────────────────────────────────────────────────────
bool xktOn = false;
unsigned long transmitterStart = 0;
bool warningStarted = false;
// ────────────────────────────────────────────────────────────
// READ NODE STATE
// ────────────────────────────────────────────────────────────
bool readState[6] = {false, false, false, false, false, false};
// ============================================================
// SETUP
// ============================================================
void setup() {
Serial.begin(115200);
Wire.begin(); // D4=SDA (GPIO22), D5=SCL (GPIO23)
Wire.setTimeOut(10); // — 10ms timeout prevents I2C hang
pinMode(SWITCH_PIN, OUTPUT);
digitalWrite(SWITCH_PIN, HIGH); // HIGH = transmitter OFF at boot
// ── Connect to WiFi ──────────────────────────────────────
Serial.print("Connecting to WiFi");
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
Serial.print(WiFi.status());
}
Serial.println();
Serial.print("Connected! IP address: ");
Serial.println(WiFi.localIP());
Serial.printf("Telnet: nc %s %d\n",
WiFi.localIP().toString().c_str(), TELNET_PORT);
telnetServer.begin();
telnetServer.setNoDelay(true);
delay(500);
tprintln("The dragon is sensing that...");
tprintln();
}
// ============================================================
// MAIN LOOP
// ============================================================
void loop() {
handleTelnetClient();
pollSequenceNodes();
pollReadNodes();
checkTransmitterTimer();
delay(50);
}
// ============================================================
// TELNET CLIENT HANDLER
// ============================================================
void handleTelnetClient() {
if (telnetServer.hasClient()) {
if (!telnetClient || !telnetClient.connected()) {
telnetClient = telnetServer.available();
tprintln("=== The telnet is connected ===");
} else {
telnetServer.available().stop();
}
}
}
// ============================================================
// TRANSMITTER TIMER CHECK
// ============================================================
void checkTransmitterTimer() {
if (!xktOn) return;
unsigned long elapsed = millis() - transmitterStart;
if (!warningStarted && elapsed >= XKT_ON_TIME - ALARM_TIME) {
warningStarted = true;
sendWarningCommand(0x01);
tprintln("the spell is losing power, cast again");
}
if (elapsed >= XKT_ON_TIME) {
disableTransmitter();
tprintln("the spell has been broken");
}
}
// ============================================================
// SEQUENCE NODE POLLING (0x10, 0x11, 0x12)
//
// Gating rules based on transmitter elapsed time:
// xktOn == false → forward allowed
// xktOn == true, elapsed < 30s → neither allowed (just cast)
// xktOn == true, 30s <= elapsed < 9:30 → reverse only
// xktOn == true, elapsed >= 9:30 → forward allowed (extend/recast)
//
// Both sequences use rising-edge detection to prevent
// a held magnet from auto-advancing.
// Forward times out if stalled past TIME_WINDOW.
// ============================================================
void pollSequenceNodes() {
unsigned long elapsed = xktOn ? millis() - transmitterStart : 0;
// ── determine what is allowed ─────────────────────────────
bool forwardAllowed = (!xktOn && millis() - powerOffTime >= OFF_LOCKOUT) ||
(xktOn && elapsed >= XKT_ON_TIME - ALARM_TIME);
bool reverseAllowed = xktOn &&
elapsed >= RECAST_WINDOW &&
elapsed < XKT_ON_TIME - ALARM_TIME;
// ── forward sequence timeout ──────────────────────────────
if (nextExpected > 0 && millis() - firstTrigger > TIME_WINDOW) {
tprintf("sequence timed out waiting for step %d — resetting\n",
nextExpected + 1);
resetForward();
}
//tprintf("DEBUG elapsed=%lu forwardAllowed=%d reverseAllowed=%d\n",
// elapsed, forwardAllowed, reverseAllowed);
// ── FORWARD sequence ──────────────────────────────────────
if (forwardAllowed) {
uint8_t addr = seqNodes[nextExpected];
Wire.requestFrom(addr, 3);
if (Wire.available() == 3) {
byte state = Wire.read();
Wire.read(); Wire.read(); // discard ADC bytes
bool forwardActive = (state == 1);
if (forwardActive && !lastForwardState) {
if (nextExpected == 0) {
firstTrigger = millis();
tprintln("the Power Spell sequence started");
}
if (millis() - firstTrigger <= TIME_WINDOW) {
nextExpected++;
if (nextExpected < NUM_SEQ) {
unsigned long secsRemaining =
(TIME_WINDOW - (millis() - firstTrigger) + 500) / 1000;
tprintf(" step %d of %d confirmed — %lu seconds remaining\n",
nextExpected, NUM_SEQ, secsRemaining);
} else {
// sequence complete
tprintf(" step %d of %d confirmed\n", nextExpected, NUM_SEQ);
if (xktOn) {
transmitterStart = millis();
warningStarted = false;
sendWarningCommand(0x00);
tprintln(">>> the Power spell has been extended <<<");
} else {
enableTransmitter();
tprintln(">>> powered for 10 min <<<");
}
lastForwardState = forwardActive;
lastReverseState = false; // clear reverse edge
resetForward();
resetReverse();
return; // exit — let transmitter timer gate next attempt
}
} else {
tprintf("step %d reached, but the spell was not completed in time — resetting\n",
nextExpected);
resetForward();
}
}
lastForwardState = forwardActive;
}
} else {
// not polling forward — keep edge state fresh so no stale rising edge
lastForwardState = false;
}
// ── REVERSE sequence ──────────────────────────────────────
if (reverseAllowed && nextExpected == 0) {
uint8_t raddr = seqNodes[nextReverse];
Wire.requestFrom(raddr, 3);
if (Wire.available() == 3) {
byte state = Wire.read();
Wire.read(); Wire.read(); // discard ADC bytes
bool reverseActive = (state == 1);
if (reverseActive && !lastReverseState) {
if (nextReverse == 2) {
firstReverse = millis();
tprintln("The Reverse Power spell sequence has started");
}
if (millis() - firstReverse <= TIME_WINDOW) {
nextReverse--;
tprintf(" Reverse step %d of %d confirmed\n",
3 - (nextReverse + 1), NUM_SEQ);
if (nextReverse < 0) {
disableTransmitter();
tprintln(">>> the spell has been undone — Power OFF <<<");
lastReverseState = reverseActive;
lastForwardState = false;
resetReverse();
return; // exit immediately
}
} else {
tprintln("Reverse spell not completed in time — resetting");
resetReverse();
}
}
lastReverseState = reverseActive;
}
} else {
// not polling reverse — clear edge state
lastReverseState = false;
}
}
// ============================================================
// READ NODE POLLING (0x13, 0x14, 0x15, 0x17, 0x18, 0x19)
// Reads outputOn state only — logs on state changes
// ============================================================
void pollReadNodes() {
for (int i = 0; i < NUM_READ; i++) {
uint8_t addr = readNodes[i];
Wire.requestFrom(addr, 3);
if (Wire.available() == 3) {
byte state = Wire.read();
byte hi = Wire.read();
byte lo = Wire.read();
int adc = (hi << 8) | lo;
bool isOn = (state == 1);
if (isOn != readState[i]) {
readState[i] = isOn;
tprintf("0x%02X %s is %s ADC=%d\n",
addr, readLabels[i], isOn ? "ON" : "OFF", adc);
}
}
}
}
// ============================================================
// HELPERS
// ============================================================
void enableTransmitter() {
xktOn = true;
transmitterStart = millis();
warningStarted = false;
digitalWrite(SWITCH_PIN, LOW); // LOW = transmitter ON (PNP)
}
void disableTransmitter() {
xktOn = false;
powerOffTime = millis(); // ← add this
warningStarted = false;
digitalWrite(SWITCH_PIN, HIGH);
sendWarningCommand(0x00);
resetReverse();
}
void sendWarningCommand(byte cmd) {
Wire.beginTransmission(WARNING_NODE);
Wire.write(cmd);
Wire.endTransmission();
}
void resetForward() {
nextExpected = 0;
firstTrigger = 0;
}
void resetReverse() {
nextReverse = 2;
firstReverse = 0;
}