Attiny412 node program

//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.

// ============================================================
//  ATtiny412 Universal Node v8
//    All nodes run identical firmware
//        - Sensor reports to ESP32-C6 Teacher
//        - ESP32-C6 Teacher handles all sequence logic
//        - Optional analog output at ATtiny node without Teacher oversight
//          - output ramps up to full power over FADE_IN_TIME
//          - fades off after OUTPUT_ON_TIME
//          - output turns off immediately if triggered again
//        - Optional digital output at ATtiny node without Teacher oversight
//
//  Sensor options:
//        - SENSOR_ANALOG: A1324 linear Hall sensor
//          analogRead + dynamic baseline deviation detection
//          responds to both magnet poles
//        - SENSOR_DIGITAL: A3144 switch Hall sensor
//          digitalRead, output LOW when south pole present
//          no baseline needed
//
//  volatile used for lastReading and sensorActive — shared between
//  main loop and I2C interrupt (sendStatus). noInterrupts() protects
//  the sensor read so I2C polling cannot corrupt mid-update values.
// ============================================================

#include <Wire.h>

// ────────────────────────────────────────────────────────────
//  STEP 1 — SET UNIQUE ADDRESS FOR THIS NODE
//  Sequence nodes:   0x10, 0x11, 0x12
//  Output nodes:     0x13, 0x14, 0x15, 0x17, 0x18, 0x19
//  Warning node:     0x16 (use attiny412_warning_node.ino)
// ────────────────────────────────────────────────────────────
#define MY_ADDRESS  0x15    // ← CHANGE THIS per node

// ────────────────────────────────────────────────────────────
//  STEP 2 — UNCOMMENT ONLY ONE NODE TYPE
// ────────────────────────────────────────────────────────────
//#define NODE_NO_OUTPUT      // sensor reading only
#define NODE_WITH_ANALOG  // PWM output from ANALOG_PIN
//#define NODE_WITH_DIGITAL // digital output from DIGITAL_PIN

// ────────────────────────────────────────────────────────────
//  STEP 3 — UNCOMMENT ONLY ONE SENSOR TYPE
//  SENSOR_ANALOG:  A1324 linear — analogRead + baseline deviation
//  SENSOR_DIGITAL: A3144 switch — digitalRead, south pole only
// ────────────────────────────────────────────────────────────
//#define SENSOR_ANALOG      // A1324 linear Hall sensor
#define SENSOR_DIGITAL   // A3144 switch Hall sensor

// ────────────────────────────────────────────────────────────
//  STEP 4 — CHECK PIN ASSIGNMENTS
// ────────────────────────────────────────────────────────────
#define SENSOR_PIN  PIN_PA3   // Hall sensor input
#define ANALOG_PIN  PIN_PA7   // analogWrite PWM output
#define DIGITAL_PIN PIN_PA6   // digitalWrite on/off output

// ────────────────────────────────────────────────────────────
//  COMPILE-TIME SAFETY CHECKS
// ────────────────────────────────────────────────────────────
#if defined(NODE_WITH_ANALOG) && defined(NODE_WITH_DIGITAL)
  #error "Cannot define both NODE_WITH_ANALOG and NODE_WITH_DIGITAL — pick one"
#endif

#if defined(SENSOR_ANALOG) && defined(SENSOR_DIGITAL)
  #error "Cannot define both SENSOR_ANALOG and SENSOR_DIGITAL — pick one"
#endif

#if !defined(SENSOR_ANALOG) && !defined(SENSOR_DIGITAL)
  #error "Must define either SENSOR_ANALOG or SENSOR_DIGITAL"
#endif

// ────────────────────────────────────────────────────────────
//  DEVIATION THRESHOLD (SENSOR_ANALOG only)
// ────────────────────────────────────────────────────────────
#define DEVIATION  10   // ADC counts from baseline — lower = more sensitive

// ────────────────────────────────────────────────────────────
//  TIMING
// ────────────────────────────────────────────────────────────
#define OUTPUT_ON_TIME  10000   // ms at full brightness before fade begins
#define FADE_DURATION    2000   // ms to fade out
#define FADE_IN_TIME     2000   // ms to fade in

// ────────────────────────────────────────────────────────────
//  STATE
//  lastReading and sensorActive are volatile — shared between
//  main loop and I2C interrupt handler sendStatus()
// ────────────────────────────────────────────────────────────
volatile int  lastReading     = 0;
volatile bool sensorActive    = false;
bool          lastSensorState = false;
bool          outputOn        = false;
unsigned long lastTriggerTime = 0;
int           pwmVoltage      = 0;
int           baseline        = 0;   // used by SENSOR_ANALOG only


// ============================================================
//  SETUP
// ============================================================
void setup() {
  Wire.begin(MY_ADDRESS);
  Wire.onRequest(sendStatus);

  // ── sensor pin mode ───────────────────────────────────────
  #ifdef SENSOR_ANALOG
    pinMode(SENSOR_PIN, INPUT);
    // baseline calibration — 64 samples over ~320ms
    long sum = 0;
    for (int i = 0; i < 64; i++) {
      sum += analogRead(SENSOR_PIN);
      delay(5);
    }
    baseline = sum / 64;
  #endif

  #ifdef SENSOR_DIGITAL
    pinMode(SENSOR_PIN, INPUT_PULLUP);  // A3144 open-collector — needs pullup
  #endif

  // ── output pin setup ──────────────────────────────────────
  #ifdef NODE_WITH_ANALOG
    pinMode(ANALOG_PIN, OUTPUT);
    analogWrite(ANALOG_PIN, 0);
  #endif

  #ifdef NODE_WITH_DIGITAL
    pinMode(DIGITAL_PIN, OUTPUT);
    digitalWrite(DIGITAL_PIN, LOW);
  #endif
}


// ============================================================
//  MAIN LOOP
// ============================================================
void loop() {

  // ── protected sensor read ─────────────────────────────────
  // noInterrupts() prevents I2C interrupt from reading
  // lastReading mid-update (would split high/low bytes)
  #ifdef SENSOR_ANALOG
    int reading = analogRead(SENSOR_PIN);
    noInterrupts();
    lastReading  = reading;
    sensorActive = abs(reading - baseline) > DEVIATION;
    interrupts();
  #endif

  #ifdef SENSOR_DIGITAL
    int reading = digitalRead(SENSOR_PIN);
    noInterrupts();
    lastReading  = reading;
    sensorActive = (reading == LOW);   // A3144 pulls LOW when south pole present
    interrupts();
  #endif

  // ── ANALOG OUTPUT ─────────────────────────────────────────
  #ifdef NODE_WITH_ANALOG
    if (sensorActive && !lastSensorState) {
      if (!outputOn) {
        // first trigger — start fade in
        outputOn        = true;
        lastTriggerTime = millis();
        pwmVoltage      = 0;
        analogWrite(ANALOG_PIN, 0);
      } else {
        // second trigger while on — turn off immediately
        outputOn   = false;
        pwmVoltage = 0;
        analogWrite(ANALOG_PIN, 0);
      }
    }

    // ── output lifecycle ──────────────────────────────────
    if (outputOn) {
      unsigned long elapsed = millis() - lastTriggerTime;

      if (elapsed >= OUTPUT_ON_TIME) {
        // fully off
        outputOn   = false;
        pwmVoltage = 0;
        analogWrite(ANALOG_PIN, 0);

      } else if (elapsed >= OUTPUT_ON_TIME - FADE_DURATION && elapsed > FADE_IN_TIME) {
        // fade out over FADE_DURATION
        unsigned long fadeElapsed = elapsed - (OUTPUT_ON_TIME - FADE_DURATION);
        pwmVoltage = map(fadeElapsed, 0, FADE_DURATION, 255, 0);
        pwmVoltage = constrain(pwmVoltage, 0, 255);
        analogWrite(ANALOG_PIN, pwmVoltage);

      } else if (elapsed < FADE_IN_TIME) {
        // fade in over FADE_IN_TIME
        pwmVoltage = map(elapsed, 0, FADE_IN_TIME, 0, 255);
        pwmVoltage = constrain(pwmVoltage, 0, 255);
        analogWrite(ANALOG_PIN, pwmVoltage);
      }
    }
  #endif

  // ── DIGITAL OUTPUT ────────────────────────────────────────
  #ifdef NODE_WITH_DIGITAL
    if (sensorActive && !lastSensorState) {
      if (!outputOn) {
        outputOn        = true;
        lastTriggerTime = millis();
        digitalWrite(DIGITAL_PIN, HIGH);
      } else {
        outputOn = false;
        digitalWrite(DIGITAL_PIN, LOW);
      }
    }
    if (outputOn && millis() - lastTriggerTime >= OUTPUT_ON_TIME) {
      outputOn = false;
      digitalWrite(DIGITAL_PIN, LOW);
    }
  #endif

  lastSensorState = sensorActive;
  delay(20);   // 20ms loop — smooth fade steps
}


// ============================================================
//  I2C CALLBACK — ESP32-C6 requests 3 bytes:
//    byte 1: outputOn state (0 or 1)
//    byte 2: raw ADC high byte
//    byte 3: raw ADC low byte
//
//  Note: for SENSOR_DIGITAL, lastReading is 0 or 1 (not 0-1023)
//  The high/low byte split is harmless for small values.
// ============================================================
void sendStatus() {
  Wire.write(outputOn ? 1 : 0);
  Wire.write((lastReading >> 8) & 0xFF);
  Wire.write(lastReading & 0xFF);
}