"""
Lab 1 - thermal · NODE firmware
school.worhl.net · MicroPython on ESP32-S3 (N16R8)

Identical on all 8 nodes. There is no node id, no address, no config
per board - because there is nothing to address. Label the boards 1-8
with tape; the firmware neither knows nor needs to know which one it is.

    The regulating scalar never becomes a packet.

This node senses the shared bath locally and throttles its own load
locally. It never transmits, never joins a network, never coordinates.
The only shared thing is the air.

Copy to a node as main.py:
    mpremote connect <port> fs cp node_lab1.py :main.py

Wiring (build sheet §2):
    DS18B20 data -> GPIO 4, with 4.7k pull-up to 3V3
    DS18B20 VDD/GND -> 3V3 / GND
    Load PWM -> GPIO 16 -> MOSFET gate  (only if HEATER = "pwm")
"""

import time
from machine import Pin

import onewire
import ds18x20

# ---------------------------------------------------------------- config
# Control law - matches build sheet §3.
SETPOINT = 30.0        # °C, the collective target
GAIN = 0.15            # droop slope: duty lost per °C above setpoint

# Cycle timing.
PERIOD_MS = 2000       # one full sense-and-act cycle
CONVERT_MS = 750       # DS18B20 12-bit conversion time

# Pins (ESP32-S3 safe GPIOs are 1-18, 21, 38-42, 47;
# 26-32 are SPI flash and 33-37 are octal PSRAM - never touch those).
PIN_SENSOR = 4
PIN_LOAD = 16

# How this node turns duty into heat.
#   "busy" - spin the CPU for the duty fraction of each window.
#            No extra hardware. Gentle authority (~0.2-0.3 W per node).
#   "pwm"  - drive a MOSFET gate switching the 10R resistor across 5V.
#            Strong authority (~2.5 W per node). Needs the optional
#            heater chain from build sheet §2 built.
HEATER = "busy"
PWM_FREQ = 1000

# Sanity band. Readings outside this are treated as a fault, not as data.
T_MIN = -20.0
T_MAX = 80.0

# The DS18B20 reports exactly 85.0 after a power-on reset with no
# completed conversion. That is a status code wearing a temperature's
# clothing, so it is never trusted as a reading.
T_POR_SENTINEL = 85.0

# Console output. Wanted during bring-up (build sheet §5, lid open).
# Nothing reads this during a real run - it is a console, not a wire.
PRINT_STATUS = True

# ------------------------------------------------------------- actuation
_pwm = None

if HEATER == "pwm":
    from machine import PWM
    _pwm = PWM(Pin(PIN_LOAD, Pin.OUT), freq=PWM_FREQ)
    _pwm.duty_u16(0)


def set_load(duty):
    """Apply duty (0.0-1.0). In busy mode this only records it."""
    global _duty
    _duty = 0.0 if duty < 0.0 else (1.0 if duty > 1.0 else duty)
    if _pwm is not None:
        _pwm.duty_u16(int(_duty * 65535))


def _spin(ms):
    """Occupy the core for ms milliseconds. This IS the heater in busy mode."""
    end = time.ticks_add(time.ticks_ms(), ms)
    x = 1
    while time.ticks_diff(end, time.ticks_ms()) > 0:
        x = (x * 31 + 7) & 0xFFFFFFF
    return x


def hold(ms):
    """Let the current duty act for ms milliseconds."""
    if _pwm is not None:
        # Hardware PWM is already running; just let time pass.
        time.sleep_ms(ms)
        return
    on = int(_duty * ms)
    if on > 0:
        _spin(on)
    if ms - on > 0:
        time.sleep_ms(ms - on)


_duty = 0.0

# ---------------------------------------------------------------- sensing
_ow = onewire.OneWire(Pin(PIN_SENSOR))
_ds = ds18x20.DS18X20(_ow)
_rom = None


def find_sensor():
    """Return the first DS18B20 rom, or None."""
    try:
        roms = _ds.scan()
    except Exception as e:
        print("# sensor scan failed:", e)
        return None
    return roms[0] if roms else None


def read_temp():
    """Return °C, or None if the reading is missing or not trustworthy."""
    if _rom is None:
        return None
    try:
        t = _ds.read_temp(_rom)
    except Exception as e:
        print("# sensor read failed:", e)
        return None
    if t is None:
        return None
    if t == T_POR_SENTINEL:
        print("# reading is the 85.0 power-on sentinel - discarded")
        return None
    if t < T_MIN or t > T_MAX:
        print("# reading out of band:", t)
        return None
    return t


# ------------------------------------------------------------- the law
def droop(t):
    """The entire control law. Local input, local output, no one told."""
    duty = 1.0 - GAIN * (t - SETPOINT)
    if duty < 0.0:
        return 0.0
    if duty > 1.0:
        return 1.0
    return duty


# ---------------------------------------------------------------- main
def main():
    global _rom

    print("# lab 1 node · setpoint %.1fC · gain %.2f · heater %s"
          % (SETPOINT, GAIN, HEATER))

    _rom = find_sensor()
    while _rom is None:
        # No sensor means no basis for regulating. Stay cold and keep looking.
        set_load(0.0)
        print("# no DS18B20 found on GPIO %d - load held at 0" % PIN_SENSOR)
        time.sleep_ms(PERIOD_MS)
        _rom = find_sensor()

    print("# sensor found:", "".join("%02x" % b for b in _rom))

    faults = 0
    while True:
        # Start a conversion, then let the current duty act while it runs.
        try:
            _ds.convert_temp()
        except Exception as e:
            print("# convert failed:", e)
        hold(CONVERT_MS)

        t = read_temp()

        if t is None:
            faults += 1
            set_load(0.0)          # fail safe: unknown bath means no heat
            if faults == 1 or faults % 10 == 0:
                print("# fault %d - load held at 0" % faults)
            if faults % 30 == 0:   # sensor may have been re-seated
                _rom = find_sensor()
        else:
            faults = 0
            set_load(droop(t))
            if PRINT_STATUS:
                print("%.2f C  duty %.3f" % (t, _duty))

        hold(PERIOD_MS - CONVERT_MS)


try:
    main()
except KeyboardInterrupt:
    set_load(0.0)
    print("# stopped by user - load off")
