Time — Professor Jeff Feddersen

Time Midterm: Little Bird Flies into a Big Black Cloud

Pen sketch in a dot grid notebook of the piece on a 12ft wall: a 1ft square panel with a feather, its cord running down to a 120V, 1A outlet, and a stick figure beside it saying WOW. The finished piece hanging on a plywood wall: gray calligraphy paper behind clear acrylic in white printed brackets, a dark feather standing at the center inside a V of black mist, and Little Bird flies into a BIG Black Cloud hand lettered along the base, with a power cord hanging below.

Parts Diagram

With a successful prototype, I felt my body vibrating with the tension of how to make this. Before I start a build like this, I like to draw a loose diagram to conceptualize how I plan the build to go. Loose, visual, flowy.

Loose parts diagram in two groups. On the assembly: calligraphy paper, plexy glass, cardboard, printed standoffs, the feather on the BLDC shaft, and a mister in water below. Inside the control box: 12v power feeding the ESP32, BLDC controller and BLDC, plus mister control, all plugged into a 120V outlet.

Massing Model

The first thing I had to do was purchase materials, but how much of each material? I answered that question with this loose massing model. This work allowed me to size the plexy glass and confirm some basic dimensions as I put together my bill of materials for ordering.

Fusion 360 massing model: a clear acrylic panel and a sheet of paper standing in front of a box, set into a long base tray, with a photo of a tape measure on the panel for scale.

Fabrication Model

I turned this around after my components arrived and started drawing them all up in a hybrid design on fusion 360. I was actually modeling each component one after another in this file, placing them in space and adding their dimensions. I was quick and dirty. As I wrapped it all up, my fusion 360 crashed and all my work (2hr of cad detailing) was lost (claude spent 1h trying to figure out why, nobody knows).

A more detailed Fusion 360 model of the same build, now with printed corner brackets holding the acrylic panels, the control box behind them and the base tray below.

Final Assembly

I took a walk and got a weed pen to calm down after my file crashed. I would remake the file, but now starting from scratch with a fresh approach. I created every one of my purchased components as an individual part file, and then used assembly to place everything I was creating from off the shelf. I used my calipers and carefully measured all the dimensions of my components.

The empty black waterproof enclosure on a wood floor with a dial caliper clamped across its width.

This was huge because I found a +3mm variance from stated dimensions for my waterproof enclosure. Devlin looked at it with me and he started laughing. There were surface defects only the sloppiest injection molding would create. At the same time, the box had its center prelocated as the site of material injection. I designed the whole thing around the central axis, so this was a win.

The final Fusion 360 assembly: two clear acrylic panels held by printed corner brackets, the control box with the motor shaft hole at its center, and the base tray with the round mister at the front.

Throughout this process, I was scrambling to produce the five 3D printed components. A mount for the motor, a motor to acrylic rod grab attachment, the base tray for the water, and the two corners. I had previously been trying to print this huge base tray, now doing damage control. The base tray redesigned to be smaller would take only 2 hours to print vs the 7 required for my larger version.

Fabrication

The first part I finished printing was the motor to acrylic rod grab.

Then, quick after I got this motor mounting.

The motor seated in its printed mount, held in hand, with a long clear acrylic rod rising from the rod grab and jumper wires looping out the side.

The mister dropped right into place, but didn't quite fit against my larger nozzle size.

A round black ultrasonic mister sitting in the channel of the translucent printed base tray, its red and black wire running off the edge.

It works fine for the first pass though. I had the laser locate my mounting holes for the plexy glass.

A hand holding the black enclosure with its clear lid, mounted on a frosted acrylic panel with printed brackets at each end and a laser browned edge along the top.

I mounted my bearing in the center of my enclosure by drilling out a nearsized hole and then sanding it open until I could press fit in my threshold bearing. I used a mallet to seat it.

The black enclosure on a workbench with a freshly drilled hole in its center, next to a Dremel with a sanding drum and a curl of blue tape. Close up of a small steel bearing pressed flush into the center hole of the black enclosure.

I had holes in my motor mount for locating its own mounting, I drilled all these out using the bldc assembly as a reference. and I located my heat set inserts inside. I had to chamfer the outside of my enclosure because it was mating surface to surface with the plexy

The inside of the empty black waterproof enclosure, with a round 3D printed mount at its center holding a small blue magnetic encoder board wired in red, yellow, blue and black. The top of the printed motor mount: three screws through the motor cap, the clear acrylic rod in the center, and brass heat set inserts in the four posts.

I cut the acrylic rod to size. Remounted it.

The clear acrylic rod pushed through the black printed rod grab, its cut end resting on purple sandpaper dusted with white acrylic powder. A hand holding the black 3D printed motor mount, four posts with brass heat set inserts around the motor, a short clear acrylic rod sticking out of its center and colored wires hanging below. The enclosure with its clear lid screwed onto the frosted acrylic panel on a black table, the printed encoder mount and wiring visible through the lid.

I had thought about running this thing straight to 120v power, but I had these waterproof nice thick 12v power stuff, so I included it.

A waterproof cable gland on the side of the enclosure with a short black 12v lead ending in a screw on connector, its red and black wires running into the white base tray.

Now I cut the calligraphy paper to size. I used scrap plexy on the bed to keep it held down. as it cut so the edges would not curl up. I cut multiple times and moved the pieces out of the way when necessary.

Dark calligraphy paper on the bed of an Epilog laser cutter, held flat by white acrylic scraps and an MDF strip, with cut offs scattered on the grid below.

Full Assembly, for the first time, felt awesome.

The first full assembly on a workbench: gray calligraphy paper behind clear acrylic in white printed brackets, a dark feather on the acrylic rod at its center like a clock hand, and a black mist stain rising from the mister below.

Kezia did the text on the front for me.

Kezia bent over a shop table, hand lettering the white base of the piece with a paint pen, with a laptop and tools on the table. Close up of hands writing Little Bird in black paint pen along the white printed base below the acrylic.

the title is a reference to this Song Snipped album by The Microphones called. There are a lot of different covers.

Album cover of Little Bird Flies Into a Big Black Cloud by The Microphones: a bird's head painted in loose red brush strokes on white, with the title lettered in green up the left edge. The finished piece seen straight on: the feather upright at the center of the gray paper, the black mist cloud spread in a V around it, and the hand lettered title along the base.

P Comp

Whats there really to say about this, I plugged everything in like it has been on my desk for the motor. I also added a RTC clock to the build because Joy had an extra. To program it, I sent a link from amazon of the same looking board and said program it to claude. It integrated the whole thing as a function call which updates the internal clock. The ESP32 also connects to wifi, so it used NTP to set the initial time.

I also added a transistor to switch control to the mister, just so I could have it as an option. Maybe Ill toggle it on or off in the future. Maybe 15 mins on at the end of the hour, idk.

The back of the clock with the enclosure open: the ESP32, a clock module with its coin battery, the encoder on the motor mount, driver boards, lever connectors and a small power board, zip tied together with a black power cable leaving the bottom.

Code

I had claude enable some things which made the process of dialing in my motor variables and reuploading code over the air. This is the nicest thing I have ever done on a project. Theres nothing worse than having to plug a physical cable into motor and diagnose serial.

Claude did everything, I directed it to examples.

The FOC TIME control page while locked: a note that Wi-Fi stays on for 10 minutes after power-up, and a PIN field with an Unlock button. The unlocked FOC TIME control page: a 12 h hand setting and Set noon button, pilot jog buttons, sliders for the motor tuning values, command buttons and a live readout of target and angle.
/**
 * STEP 2: angle control -- from hardware_specific_examples/ESP32/magnetic_sensor/esp32_position_control.ino
 * Changed: 2804 gimbal motor (7 pole pairs, 12N14P), AS5600 over I2C, driver pins 25/26/27/33, supply from FOC 2815,
 * controller = angle.
 * Clock: Wi-Fi + NTP time from the Arduino-ESP32 core example Time/SimpleTime.ino drives the 24h hand.
 * RTC: DS3231 module (RTClib examples/ds3231/ds3231.ino, via rtc_test.cpp) starts the clock at boot and keeps
 * it without Wi-Fi; every NTP sync writes the time back into it.
 * OTA: firmware updates over Wi-Fi from the Arduino-ESP32 core example ArduinoOTA/BasicOTA.ino
 * (pio run -e ota -t upload), plus pull updates with the core's HTTPUpdate: GET /update makes the clock
 * download the new firmware itself (see WIFI_FLASH.md).
 */
#include <Arduino.h>
#include <SimpleFOC.h>
#include <WiFi.h>
#include <ESPmDNS.h>
#include <ArduinoOTA.h>
#include <WebServer.h>
#include <HTTPUpdate.h>
#include <StreamString.h>
#include <Preferences.h>
#include <time.h>
#include <sys/time.h>
#include "esp_sntp.h"
#include "RTClib.h"
#include "secrets.h"

// ---- motor tuning (applied in setup) ----
const int   POLE_PAIRS       = 7;      // 2804, PP check OK
const float SUPPLY_VOLTAGE   = 12;     // power supply voltage [V]
const float ALIGN_VOLTAGE    = 1;      // aligning voltage [V] -- from FOC 2815
const float VOLTAGE_LIMIT    = 1;      // [V] was 4
const float VEL_P            = 0.1f;   // velocity PI controller, was 0.01 (I did all the work -> wobble)
const float VEL_I            = 2;      // was 20: limit-cycled on the 2804
const float VEL_RAMP         = 50;     // jerk control [V/s], default 300, was 100
const float VEL_LPF_TF       = 0.02f;  // velocity low pass filtering time constant [s], was 0.01
const float ANGLE_P          = 5;      // angle P controller, was 10
const float VELOCITY_LIMIT   = 0.5;    // maximal velocity of the position control [rad/s], was 1
const int   MOTION_DOWNSAMPLE = 10;    // move() every 10th loop, was 0: per-loop AS5600 velocity was mostly noise (1 count = 6 rad/s)

// Motor instance
BLDCMotor motor = BLDCMotor(POLE_PAIRS);
// BLDCDriver3PWM(IN1, IN2, IN3, enable)
BLDCDriver3PWM driver = BLDCDriver3PWM(25, 26, 27, 33);

// I2C Magnetic sensor instance (AS5600 example)
// make sure to use the pull-ups!!
// SDA 21
// SCL 22
MagneticSensorI2C sensor = MagneticSensorI2C(AS5600_I2C);

// mister: transistor switch on GPIO 15 (HIGH = on), blows steam across the feather and canvas
const int MISTER_PIN = 15;

// RTC: DS3231 + AT24C32 module on its own I2C bus (Wire1), off the AS5600's; kept in UTC
const int RTC_SDA = 16;
const int RTC_SCL = 17;
RTC_DS3231 rtc;
bool rtc_ok = false;                  // RTC answered at boot
volatile uint32_t ntp_events = 0;     // NTP syncs, counted by the SNTP callback (a count can't lose a second sync)
uint32_t ntp_seen = 0;                // ntp_events already handled in loop()
bool rtc_write_pending = false;       // NTP time waiting to go into the RTC at a second boundary
unsigned long rtc_write_since = 0;    // millis() when it started waiting
unsigned rtc_failures = 0;            // RTC probes failed in a row (R)

// one cheap probe of the RTC (Wire1 times out after 5 ms), so a missing RTC can't stall the motor loop
bool rtcAnswers() {
  Wire1.beginTransmission(0x68);
  bool ok = Wire1.endTransmission() == 0;
  rtc_failures = ok ? 0 : rtc_failures + 1;
  return ok;
}

// angle set point variable -- double: 24h-mode steps (~1.5e-6 rad per 20 ms) get lost in float rounding.
// Clockwise only: it never moves against HAND_DIR (updateClockTarget)
double target_angle = 0;

// live tuning over serial -- from SimpleFOC examples/utils/communication_test/commander
// e.g. MVP0.3 vel P, MVI1 vel I, MVR100 ramp, MVF0.01 vel LPF, MAP10 angle P, MCD10 motion downsample,
//      MLV1 vel limit, MLU4 voltage limit, ME0/ME1 disable/enable, MMD0 stop monitor output, M get all
//      S1/S0 mister on/off, N set noon (twice, see doNoon), H24/H12/H60 hand mode, MMG7 all states, ? list commands,
//      TS save tuning, TD back to the consts, P10 jog the hand 10° (clock paused), PC back to the clock
// (the clock owns the target, so "M<number>" is overwritten; tuning resets to the consts on reboot unless saved)
// The same commands work from the web page at http://<clock IP>/ (sliders and buttons, see handleCmd)
Commander command = Commander(Serial);
void doMotor(char* cmd) { command.motor(&motor, cmd); }
void doMister(char* cmd) {
  bool on = atoi(cmd) != 0;
  digitalWrite(MISTER_PIN, on ? HIGH : LOW);
  command.com_port->println(on ? "Mister on" : "Mister off");  // to whoever sent it: USB or the web page
}

// clock (not from SimpleFOC examples)
const char *time_zone = "EST5EDT,M3.2.0,M11.1.0";  // TimeZone rule for New York incl. daylight saving
const float HAND_DIR = 1;     // +1 or -1, whichever turns the hand clockwise (the hand only ever turns that way)
const double HOLD_MAX = 20 * _PI / 180;  // [rad] time up to this far behind the hand: it waits for the time to catch up
                                         // (daylight-saving fall-back = 15° in 24 h mode); further behind: forward the long way.
                                         // Doubled in 12 h mode, where the fall-back is 30°
// set over serial / the web page and kept in flash (Preferences "clock"):
float top_angle = 0;          // shaft angle [rad, 0..2pi) with the hand at the top: noon (24 h), 12 (12 h) or 0 s (60 s), N
int hand_turn = 24;           // one turn of the hand per 24 h, 12 h or 60 s (testing), H24 / H12 / H60
bool noon_armed = false;      // N pressed once: motor off, waiting for the hand to be turned to the top
Preferences prefs;
Preferences tuning;           // motor tuning saved with TS, used instead of the consts at the top at boot

// a saved float, or def if it was never saved (getFloat alone logs an error for a missing key)
float savedFloat(Preferences &p, const char *key, float def) { return p.isKey(key) ? p.getFloat(key, def) : def; }
bool time_synced = false;
double time_angle = 0;        // where the time is, unwrapped like target_angle but free to step back
double last_day_angle = 0;
bool motor_on_last_loop = false;  // turning the motor back on re-syncs from the shaft (loop)
// P: manual pilot, the clock pauses while the hand is jogged; not saved, so a reboot is always the clock
const unsigned long PILOT_TIMEOUT_MS = 10UL * 60 * 1000;  // back to the clock this long after the last jog
bool piloting = false;
unsigned long pilot_last = 0;    // millis() of the last jog
unsigned long last_clock_update = 0;

// OTA updates over Wi-Fi, password OTA_PASS in secrets.h
const char *OTA_HOSTNAME = "foc-time";  // -> foc-time.local, as upload_port in platformio.ini
bool ota_started = false;
bool ota_motor_was_enabled = false;
unsigned long last_ota_check = 0;

// pull updates: GET /update (digest auth, user "ota", password OTA_PASS) makes the clock download
// http://<the requester>:8000/firmware.bin itself -- a streaming download copes with Wi-Fi stalls that
// broke espota's chunk-by-chunk pushes
WebServer server(80);
HTTPUpdate httpUpdater(30000);  // 30 s read timeout (default 8 s)
String update_url;              // set by /update, run from loop() once the HTTP reply has gone out
unsigned long last_bad_pin = 0; // millis() of the last wrong control page PIN
uint32_t last_bad_ip = 0;       // and who sent it

// Wi-Fi only when needed, so it stays off the motor loop the rest of the time: for 10 min after boot or after the
// last use of the control page / updates, and at midnight for the daily NTP sync (which also writes the RTC)
const unsigned long WIFI_WINDOW_MS = 10UL * 60 * 1000;
const unsigned long NIGHT_SYNC_MS = 3UL * 60 * 1000;  // give up on the midnight NTP sync after this
bool wifi_on = false;
bool web_window = true;          // on for the boot window / web use
unsigned long web_last_use = 0;  // millis() of boot or of the last web request
bool night_sync = false;         // on for the midnight NTP sync, off again once it synced
unsigned long night_start = 0;
int synced_day = -1;             // tm_yday of the last daily sync (the boot window counts for its day)
bool wifi_wake = false;          // W<seconds>: Wi-Fi back on after a pause
unsigned long wake_start = 0, wake_after_ms = 0;
unsigned long last_wifi_check = 0;
time_t last_ntp_sync = 0;        // when NTP last set the clock (W)
time_t last_night_sync = 0;      // when the last midnight NTP sync succeeded, kept in flash to check it later (W)
void webUsed() { web_window = true; web_last_use = millis(); }

// RTC check once a minute: between the daily NTP syncs the clock follows the DS3231 (+-2 ppm), not the ESP32 crystal
unsigned long last_rtc_check = 0, last_rtc_poll = 0;
uint32_t rtc_check_from = 0;     // RTC seconds when the running check began, 0 = no check running
long last_rtc_drift_ms = 0;      // system clock minus RTC at the last check (R)

// loop period with Wi-Fi on / off, since that state last began (L)
struct LoopStats { uint32_t count = 0; uint64_t total_us = 0; uint32_t max_us = 0; };
LoopStats loop_on, loop_off;
uint32_t last_loop_us = 0;
bool skip_loop_sample = true;    // the pass that switched Wi-Fi isn't a normal loop

// wrap an angle difference to [-pi, pi) -- double, like the rest of the clock path: in float the
// 24h hand drifted ~1-5 min/day (float32 sim of one day of 20 ms updates)
double wrapDelta(double a) {
  a = fmod(a + _PI, _2PI);
  if (a < 0) a += _2PI;
  return a - _PI;
}

// time of day -> target angle, unwrapped so the hand keeps going forward through midnight, and clockwise only
void updateClockTarget() {
  if (piloting) return;  // the jogs own the target; leaving the pilot re-syncs
  struct timeval tv;
  gettimeofday(&tv, nullptr);  // sub-second part so the target moves continuously, not in 1 s steps
  time_t now = tv.tv_sec;
  if (now < 1700000000) return;  // not synced yet (time() still near 1970)
  struct tm t;
  localtime_r(&now, &t);
  double sec = t.tm_sec + tv.tv_usec / 1e6;
  double day_sec = t.tm_hour * 3600 + t.tm_min * 60 + sec;
  double from_top = hand_turn == 60 ? sec / 60.0              // the top is 0 s
                  : hand_turn == 12 ? day_sec / 43200.0       // 12 o'clock
                                    : day_sec / 86400.0 - 0.5;  // noon
  double day_angle = top_angle + HAND_DIR * from_top * _2PI;
  if (!time_synced) {
    // (re-)sync from the shaft: the time on the turn nearest it (AS5600 is absolute within one turn)
    target_angle = motor.shaft_angle;
    time_angle = target_angle + wrapDelta(day_angle - target_angle);
    time_synced = true;
    Serial.println(&t, "Time synced: %Y-%m-%d %H:%M:%S");
  } else {
    time_angle += wrapDelta(day_angle - last_day_angle);
  }
  last_day_angle = day_angle;
  // clockwise only: time behind the hand (re-sync, clock correction, daylight-saving fall-back) holds the hand until
  // the time catches up -- or, further behind, a turn on, so the hand goes forward the long way
  double hold = hand_turn == 12 ? 2 * HOLD_MAX : HOLD_MAX;  // 80 min on both hour dials
  if (HAND_DIR * (target_angle - time_angle) > hold) time_angle += HAND_DIR * _2PI;
  if (HAND_DIR * (time_angle - target_angle) > 0) target_angle = time_angle;
}

// N: set noon. The first N switches the motor off so the hand can be turned to the top of the dial by hand;
// the second saves that position as the top and switches the motor back on (Motor on in between cancels)
void doNoon(char* cmd) {
  if (!noon_armed || motor.enabled) {
    motor.disable();
    noon_armed = true;
    command.com_port->println("Motor off: turn the hand to the top, then press Set noon (N) again");
    return;
  }
  top_angle = fmod(motor.shaft_angle, _2PI);
  if (top_angle < 0) top_angle += _2PI;
  bool saved = prefs.putFloat("top", top_angle) == sizeof(float);
  noon_armed = false;
  motor.enable();  // loop() re-syncs from the shaft before the motor takes over: the hand goes from the top to the time
  command.com_port->printf(saved ? "Top saved (%.3f rad), motor on\n" : "Top set (%.3f rad) but saving it failed, motor on\n",
                           top_angle);
}

// H24 / H12 / H60: one turn of the hand per 24 h, 12 h or 60 s, saved; H alone reports the mode
void doHandMode(char* cmd) {
  int turn = atoi(cmd);
  if (turn == 24 || turn == 12 || turn == 60) {
    hand_turn = turn;
    if (!prefs.putUChar("turn", hand_turn)) command.com_port->print("Saving the mode failed. ");
    time_synced = false;  // re-sync: the hand goes forward to the new position (or waits, see updateClockTarget)
  }
  command.com_port->println(hand_turn == 60 ? "Hand: 60 s" : hand_turn == 12 ? "Hand: 12 h" : "Hand: 24 h");
}

// P<degrees>: pause the clock and jog the hand that far, clockwise only -- a minus jog goes forward the long way
// round to the same dial position. PC: back to the clock (re-sync from where the hand is). P alone: which one
void doPilot(char* cmd) {
  if (cmd[0] == 'C') {
    piloting = false;
    time_synced = false;
  } else if (isDigit(cmd[0]) || cmd[0] == '-' || cmd[0] == '+') {  // not "P" + eol alone
    double deg = fmod(atof(cmd), 360.0);
    if (deg < 0) deg += 360;
    piloting = true;
    pilot_last = millis();
    target_angle += HAND_DIR * deg * _PI / 180;  // from the target, so quick presses add up while the hand moves
  }
  command.com_port->println(piloting ? "Pilot: manual" : "Pilot: clock");
}

// TS: save the live motor tuning (what the sliders show) to flash, used instead of the consts at the top on
// every boot. TD: forget it and go back to the consts right away. T alone: which one is in use
void doTuning(char* cmd) {
  if (cmd[0] == 'S') {
    bool ok = tuning.putFloat("volt_lim", motor.voltage_limit) && tuning.putFloat("vel_p", motor.PID_velocity.P) &&
              tuning.putFloat("vel_i", motor.PID_velocity.I) && tuning.putFloat("vel_ramp", motor.PID_velocity.output_ramp) &&
              tuning.putFloat("vel_tf", motor.LPF_velocity.Tf) && tuning.putFloat("angle_p", motor.P_angle.P) &&
              tuning.putFloat("vel_lim", motor.velocity_limit) && tuning.putUInt("downsample", motor.motion_downsample);
    if (!ok) command.com_port->print("Saving the tuning failed. ");
  } else if (cmd[0] == 'D') {
    if (!tuning.clear()) command.com_port->print("Clearing the saved tuning failed. ");
    motor.updateVoltageLimit(VOLTAGE_LIMIT);  // the same calls Commander makes for MLU / MLV
    motor.PID_velocity.P = VEL_P;
    motor.PID_velocity.I = VEL_I;
    motor.PID_velocity.output_ramp = VEL_RAMP;
    motor.LPF_velocity.Tf = VEL_LPF_TF;
    motor.P_angle.P = ANGLE_P;
    motor.updateVelocityLimit(VELOCITY_LIMIT);
    motor.motion_downsample = MOTION_DOWNSAMPLE;
  }
  command.com_port->println(tuning.isKey("vel_p") ? "Tuning: saved values" : "Tuning: defaults from main.cpp");
}

// check the RTC and, if its time is valid, start the system clock from it so the hand is right before
// (or without) Wi-Fi. Waits for the next RTC tick (up to 1 s) so the second starts on time.
bool checkRTC() {
  if (!rtc.begin(&Wire1)) {
    Serial.printf("RTC not found -- check SDA %d / SCL %d / 3.3 V / GND\n", RTC_SDA, RTC_SCL);
    return false;
  }
  if (rtc.lostPower()) {
    Serial.println("RTC lost power: time invalid until NTP sets it");
    return true;
  }
  uint32_t start = rtc.now().unixtime(), t;
  unsigned long waited = millis();
  do {
    delay(2);
    t = rtc.now().unixtime();
  } while (t == start && millis() - waited < 1100);
  if (t == start) {
    Serial.println("RTC answers but isn't ticking");
    return true;
  }
  if (time(nullptr) < 1700000000) {  // unless NTP got there first
    struct timeval tv = { (time_t)t, 0 };
    settimeofday(&tv, nullptr);
  }
  Serial.printf("RTC time %lu UTC, %.2f C\n", (unsigned long)t, rtc.getTemperature());
  return true;
}

// SNTP callback (runs in the network task): only count it, loop() does the rest
void onNtpSync(struct timeval *tv) { ntp_events = ntp_events + 1; }

// control page: sliders for the tuning values and buttons for the serial commands, all sent through /cmd
const char CONTROL_PAGE[] PROGMEM = R"HTML(<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>FOC TIME</title>
<style>
body{font-family:system-ui,sans-serif;max-width:40em;margin:1em auto;padding:0 1em}
.row{display:grid;grid-template-columns:11em 1fr 6em;gap:.6em;align-items:center;margin:.5em 0}
.row input[type=number]{width:5.5em}
button{margin:.2em .2em .2em 0;padding:.5em .8em}
pre{background:#f2f2f2;padding:.6em;white-space:pre-wrap;min-height:1.5em}
</style></head><body>
<h2>FOC TIME</h2>
<p id="build"></p>
<p><small>Wi-Fi stays on for 10 minutes after power-up or after you last used this page, then turns off until
midnight (time sync) or the next power-up.</small></p>
<div id="lock"><p>PIN <input id="pin" type="password" inputmode="numeric" size="8">
<button id="unlock">Unlock</button> <span id="msg"></span></p></div>
<div id="panel" hidden>
<h3>Clock</h3>
<p>Hand <select id="mode"><option value="H24">24 h (noon at the top)</option><option value="H12">12 h</option>
<option value="H60">60 s (test)</option></select></p>
<p><button id="noon">Set noon</button> <small>Press once and the motor lets go: turn the hand to the top,
then press again to save it.</small></p>
<h3>Pilot</h3>
<p><span id="jogs"></span> <button id="clock">Back to clock</button> <span id="pilot"></span></p>
<p><small>A jog pauses the clock and turns the hand clockwise; a minus jog goes the long way round to that
position. The clock takes over again with Back to clock, or 10 minutes after the last jog.</small></p>
<h3>Motor settings</h3>
<div id="sliders"></div>
<p><button id="save">Save tuning</button> <button id="defaults">Load defaults</button> <span id="tuning"></span></p>
<p><small>Slider changes apply live. Save tuning keeps them across reboots; Load defaults goes back to the
values in main.cpp. Set noon and the hand mode are saved as soon as you use them.</small></p>
<h3>Commands</h3>
<div id="buttons"></div>
<p><input id="free" size="26" placeholder="any serial command, e.g. MVP0.2"> <button id="sendfree">Send</button></p>
<pre id="out"></pre>
<h3>Live</h3>
<pre id="live"></pre>
</div>
<script>
const SLIDERS = [
  ["MLU", "Voltage limit [V]", 0, 6, 0.1],
  ["MVP", "Velocity P", 0, 0.5, 0.005],
  ["MVI", "Velocity I", 0, 10, 0.1],
  ["MVR", "Velocity ramp [V/s]", 0, 1000, 10],
  ["MVF", "Velocity filter Tf [s]", 0, 0.1, 0.001],
  ["MAP", "Angle P", 0, 30, 0.5],
  ["MLV", "Velocity limit [rad/s]", 0, 5, 0.05],
  ["MCD", "Motion downsample", 0, 50, 1]
];
const BUTTONS = [
  ["Motor on", "ME1"], ["Motor off", "ME0"],
  ["Mister on", "S1"], ["Mister off", "S0"],
  ["Serial monitor on", "MMD100"], ["Serial monitor off", "MMD0"],
  ["All states", "MMG7"], ["Loop timing", "L"], ["RTC status", "R"],
  ["Wi-Fi status", "W"], ["Wi-Fi off now", "W0"], ["List commands", "?"]
];
const JOGS = [-90, -10, -1, 1, 10, 90];  // degrees, P command
const $ = id => document.getElementById(id);
let pin = localStorage.getItem("pin") || "";  // remembered after the first unlock
async function cmd(c) {
  let r;
  try { r = await fetch("/cmd?k=" + encodeURIComponent(pin) + "&c=" + encodeURIComponent(c)); }
  catch (e) { throw new Error("Clock not reachable: its Wi-Fi may be off, power-cycle it to turn Wi-Fi back on"); }
  if (r.status === 401) throw new Error("Wrong PIN");
  if (r.status === 429) throw new Error("Wait a second after a wrong PIN, then try again");
  if (!r.ok) throw new Error("HTTP " + r.status);
  return (await r.text()).trim();
}
async function send(c) {
  let reply;
  try { reply = await cmd(c); } catch (e) { reply = e.message; }
  $("out").textContent = "> " + c + "\n" + reply;
  return reply;
}
function lastNumber(t) {
  const m = t.match(/-?\d*\.?\d+/g);
  return m ? parseFloat(m[m.length - 1]) : NaN;
}
async function live() {
  let t;
  try { t = await cmd("MMG7"); } catch (e) { $("live").textContent = e.message; return; }  // all: target;Vq;Vd;Cq;Cd;vel;angle
  const v = t.replace(/^.*?:/, "").split(";").map(parseFloat);
  if (v.length < 7) { $("live").textContent = t; return; }
  $("live").textContent = "target " + v[0].toFixed(3) + " rad   angle " + v[6].toFixed(3) +
    " rad   error " + (v[0] - v[6]).toFixed(3) + " rad\nvelocity " + v[5].toFixed(3) +
    " rad/s   Vq " + v[1].toFixed(3) + " V";
}
const rows = [];  // the sliders: read their values back from the clock
async function refresh() {
  for (const r of rows) r.range.value = r.box.value = lastNumber(await cmd(r.c));
}
async function init() {
  const showMode = reply => { const m = reply.match(/Hand: (\d+)/); if (m) $("mode").value = "H" + m[1]; };
  showMode(await cmd("H"));
  $("mode").onchange = async () => showMode(await send($("mode").value));
  $("noon").onclick = () => send("N");
  for (const d of JOGS) {
    const b = document.createElement("button");
    b.textContent = (d > 0 ? "+" : "") + d + "°";
    b.onclick = async () => { $("pilot").textContent = await send("P" + d); };
    $("jogs").append(b);
  }
  $("clock").onclick = async () => { $("pilot").textContent = await send("PC"); };
  $("pilot").textContent = await cmd("P");
  $("tuning").textContent = await cmd("T");
  $("save").onclick = async () => { $("tuning").textContent = await send("TS"); };
  $("defaults").onclick = async () => { $("tuning").textContent = await send("TD"); await refresh(); };
  for (const [c, label, min, max, step] of SLIDERS) {
    const row = document.createElement("div");
    row.className = "row";
    row.innerHTML = "<label>" + label + "</label><input type=range min=" + min + " max=" + max + " step=" + step +
                    "><input type=number min=" + min + " max=" + max + " step=" + step + ">";
    $("sliders").append(row);
    const [range, box] = row.querySelectorAll("input");
    const set = async v => { range.value = box.value = lastNumber(await send(c + v)); };
    range.oninput = () => { box.value = range.value; };
    range.onchange = () => set(range.value);
    box.onchange = () => set(box.value);
    rows.push({ c, range, box });
  }
  await refresh();
  for (const [label, c] of BUTTONS) {
    const b = document.createElement("button");
    b.textContent = label;
    b.onclick = () => send(c);
    $("buttons").append(b);
  }
  $("sendfree").onclick = () => send($("free").value);
  $("free").onkeydown = e => { if (e.key === "Enter") send($("free").value); };
  setInterval(live, 1000);
}
let started = false;
async function unlock() {
  pin = $("pin").value.trim();
  try { await cmd("MLU"); } catch (e) { $("msg").textContent = e.message; return; }  // a read, to test the PIN
  localStorage.setItem("pin", pin);
  $("lock").hidden = true;
  $("panel").hidden = false;
  if (!started) { started = true; init(); }
}
$("unlock").onclick = unlock;
$("pin").onkeydown = e => { if (e.key === "Enter") unlock(); };
fetch("/version").then(r => r.text()).then(t => { $("build").textContent = t; });
if (pin) { $("pin").value = pin; unlock(); }
</script></body></html>
)HTML";

// /update needs the OTA password (digest auth, user "ota")
bool authorized() {
  if (server.authenticate("ota", OTA_PASS)) return true;
  server.requestAuthentication(DIGEST_AUTH);
  return false;
}

// /cmd needs WEB_PIN (secrets.h) as ?k= -- the browser's own digest login kept failing. After a wrong PIN,
// /cmd refuses everything for 1 s: guessing slows to one try a second without blocking the motor loop
bool pinOK() {
  uint32_t ip = server.client().remoteIP();  // the lockout only holds back whoever got it wrong
  if (last_bad_pin && ip == last_bad_ip && millis() - last_bad_pin < 1000) {
    server.send(429, "text/plain", "wait\n");
    return false;
  }
  if (server.arg("k") == WEB_PIN) return true;
  last_bad_pin = millis();
  last_bad_ip = ip;
  server.send(401, "text/plain", "wrong PIN\n");
  return false;
}

// GET /: the control page (nothing secret in it; the PIN is checked on /cmd). Loading it doesn't keep Wi-Fi on:
// only PIN-checked use does
void handleRoot() {
  server.send_P(200, "text/html", CONTROL_PAGE);
}

// GET /version: which firmware is running (no password, to check an update landed)
void handleVersion() {
  server.send(200, "text/plain", "FOC TIME build " __DATE__ " " __TIME__ "\n");
}

// GET /cmd?c=MVP0.1: run any serial (Commander) command, exactly as typed over USB, and return its reply
void handleCmd() {
  if (!pinOK()) return;
  // end it like a typed line: Commander only reads "MVP" as a query when the eol follows,
  // without it "MVP" sets P = 0
  String c = server.arg("c") + "\n";
  if (!c.startsWith("MMG")) webUsed();  // someone using the page keeps Wi-Fi on; the live readout's poll doesn't
  StreamString reply;
  command.com_port = &reply;  // capture the reply instead of printing it to USB
  command.run((char*)c.c_str());
  command.com_port = &Serial;
  server.send(200, "text/plain", reply);
}

// GET /update[?url=...]: queue a pull update from the requester's port 8000 (or from url)
void handleUpdate() {
  if (!authorized()) return;
  webUsed();
  update_url = server.hasArg("url") ? server.arg("url")
             : "http://" + server.client().remoteIP().toString() + ":8000/firmware.bin";
  server.send(202, "text/plain", "updating from " + update_url + "\n");
}

// download and flash update_url; blocks loop() for the download and reboots on success
void runPullUpdate() {
  Serial.printf("Update from %s\n", update_url.c_str());
  bool was_enabled = motor.enabled;
  motor.disable();  // loop() stops during the download: don't leave the windings energized
  motor_on_last_loop = false;  // so loop() sees the motor come back on after a failed update and re-syncs
  httpUpdater.onProgress([](int done, int total) {
    static int last_pct = -1;
    int pct = total > 0 ? (int)(100LL * done / total) : 0;
    if (pct != last_pct) {
      last_pct = pct;
      Serial.printf("Update %d%%\r", pct);
    }
  });
  WiFiClient client;
  httpUpdater.update(client, update_url);  // only returns if it failed
  Serial.printf("\nUpdate failed: %s\n", httpUpdater.getLastErrorString().c_str());
  if (was_enabled) motor.enable();  // the old firmware keeps running
  update_url = "";
}

// OTA -- from BasicOTA.ino, but started from loop() once Wi-Fi is up: the example blocks setup()
// until Wi-Fi connects, which would hold up the motor and the clock
void startOTA() {
  ArduinoOTA.setHostname(OTA_HOSTNAME);
  ArduinoOTA.setPassword(OTA_PASS);
  ArduinoOTA.setTimeout(10000);  // per-chunk wait, default 1 s: this Wi-Fi stalls for seconds and killed uploads
  ArduinoOTA
    .onStart([]() {
      // loop() stops during the transfer: don't leave the windings energized
      ota_motor_was_enabled = motor.enabled;
      motor.disable();
      motor_on_last_loop = false;  // so loop() re-syncs if onError gives the motor back
      Serial.println("OTA start");
    })
    .onEnd([]() {
      Serial.println("\nOTA end, rebooting");
    })
    .onProgress([](unsigned int progress, unsigned int total) {
      Serial.printf("OTA %u%%\r", (progress / (total / 100)));
    })
    .onError([](ota_error_t error) {
      Serial.printf("OTA error[%u]: ", error);
      if (error == OTA_AUTH_ERROR) Serial.println("Auth Failed");
      else if (error == OTA_BEGIN_ERROR) Serial.println("Begin Failed");
      else if (error == OTA_CONNECT_ERROR) Serial.println("Connect Failed");
      else if (error == OTA_RECEIVE_ERROR) Serial.println("Receive Failed");
      else if (error == OTA_END_ERROR) Serial.println("End Failed");
      // the old firmware keeps running: give the hand back to the motor
      if (ota_motor_was_enabled) {
        motor.enable();
        ota_motor_was_enabled = false;
      }
    });
  ArduinoOTA.begin();
  Serial.printf("OTA ready: %s.local (%s, RSSI %d dBm)\n", OTA_HOSTNAME, WiFi.localIP().toString().c_str(), WiFi.RSSI());

  server.begin();  // routes are set up once in setup()
  Serial.printf("Update server: http://%s/update\n", WiFi.localIP().toString().c_str());
}

// Wi-Fi on: connect and (re)start SNTP; OTA and the web server start in loop() once connected
void wifiOn() {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  WiFi.setSleep(false);  // no modem power save: with it, pings took 0.1-4.5 s and OTA uploads stalled
  configTzTime(time_zone, "pool.ntp.org", "time.nist.gov");  // restarts SNTP: it syncs soon after connecting
  wifi_on = true;
  loop_on = LoopStats();
  skip_loop_sample = true;
  Serial.println("Wi-Fi on");
}

// Wi-Fi off: OTA, the web server and SNTP stop, and the radio goes off
void wifiOff() {
  if (ota_started) {
    ArduinoOTA.end();
    server.stop();
    ota_started = false;
  }
  esp_sntp_stop();
  WiFi.disconnect(true);
  WiFi.mode(WIFI_OFF);
  wifi_on = false;
  loop_off = LoopStats();
  skip_loop_sample = true;
  Serial.println("Wi-Fi off");
}

// once a second: midnight starts the daily NTP sync; Wi-Fi is on while any reason for it holds
void wifiWindow() {
  time_t now = time(nullptr);
  if (now > 1700000000) {
    struct tm t;
    localtime_r(&now, &t);
    if (synced_day < 0) {
      synced_day = t.tm_yday;  // the boot window syncs the first day
    } else if (t.tm_yday != synced_day) {  // midnight
      synced_day = t.tm_yday;
      night_sync = true;
      night_start = millis();
      if (wifi_on) configTzTime(time_zone, "pool.ntp.org", "time.nist.gov");  // already on: sync again now
      Serial.println("Midnight: NTP sync");
    }
  }
  if (web_window && millis() - web_last_use >= WIFI_WINDOW_MS) web_window = false;
  if (night_sync && millis() - night_start >= NIGHT_SYNC_MS) night_sync = false;  // no NTP tonight
  if (wifi_wake && !wifi_on && millis() - wake_start >= wake_after_ms) {  // only once it really was off (W1)
    wifi_wake = false;
    webUsed();
  }
  bool want = web_window || night_sync;
  if (want && !wifi_on) wifiOn();
  else if (!want && wifi_on) wifiOff();
}

// once a minute: poll the RTC every 5 ms until its seconds tick over, then put the system clock on that tick
// (a few 0.3 ms reads on Wire1, off the AS5600's bus). Skipped while an NTP time waits to be written into it
void rtcCheck() {
  if (!rtc_ok || rtc_write_pending) return;
  if (!rtc_check_from) {
    if (millis() - last_rtc_check < 60000) return;
    last_rtc_check = last_rtc_poll = millis();
    if (rtcAnswers()) rtc_check_from = rtc.now().unixtime();  // else: try again next minute (R shows it)
    return;
  }
  if (millis() - last_rtc_poll < 5) return;
  unsigned long gap = millis() - last_rtc_poll;  // since the previous look at the RTC
  last_rtc_poll = millis();
  struct timeval tv;
  gettimeofday(&tv, nullptr);  // system time just before the read that sees the tick
  uint32_t t = rtc.now().unixtime();
  if (millis() - last_rtc_check > 2500) {  // no clean tick in time (RTC stopped, or the loop too busy): next minute
    rtc_check_from = 0;
    return;
  }
  if (t == rtc_check_from) return;
  if (gap > 12) {  // it ticked during a long pause, so when exactly is unknown: wait for the next tick
    rtc_check_from = t;
    return;
  }
  rtc_check_from = 0;
  int64_t drift_ms = ((int64_t)tv.tv_sec - (int64_t)t) * 1000 + tv.tv_usec / 1000;  // 64 bit: no overflow
  if (t < 1700000000 || drift_ms > 3600000 || drift_ms < -3600000) return;  // RTC time not plausible: keep ours
  last_rtc_drift_ms = (long)drift_ms;
  if ((drift_ms > 10 || drift_ms < -10) && ntp_events == ntp_seen) {  // >10 ms off, and NTP didn't just set it
    struct timeval rt = { (time_t)t, 0 };
    settimeofday(&rt, nullptr);
  }
}

// W<seconds>: Wi-Fi off now, back on after that many seconds (W0: until the next boot or midnight).
// W alone: Wi-Fi state and the last NTP sync
void doWifi(char* cmd) {
  if (isDigit(cmd[0])) {
    unsigned long s = strtoul(cmd, nullptr, 10);
    if (s > 86400) s = 86400;  // at most a day, and no overflow in the ms below
    web_window = false;
    night_sync = false;
    wifi_wake = s > 0;
    wake_start = millis();
    wake_after_ms = s * 1000UL;
    if (s) command.com_port->printf("Wi-Fi off in a second, back on in %lu s\n", s);
    else command.com_port->println("Wi-Fi off in a second, back at the next boot or midnight");
    return;
  }
  char synced[24] = "never", night[24] = "never";
  struct tm t;
  if (last_ntp_sync && localtime_r(&last_ntp_sync, &t)) strftime(synced, sizeof(synced), "%Y-%m-%d %H:%M:%S", &t);
  if (last_night_sync && localtime_r(&last_night_sync, &t)) strftime(night, sizeof(night), "%Y-%m-%d %H:%M:%S", &t);
  command.com_port->printf("Wi-Fi %s", wifi_on ? "on" : "off");
  unsigned long used = millis() - web_last_use;
  if (web_window) command.com_port->printf(", off in %lu s unless used", used < WIFI_WINDOW_MS ? (WIFI_WINDOW_MS - used) / 1000 : 0);
  if (night_sync) command.com_port->print(", midnight NTP sync running");
  command.com_port->printf(", last NTP sync %s, last midnight sync %s\n", synced, night);
}

// L: loop period with Wi-Fi on and with Wi-Fi off, since each state last began (max = the longest single pass)
void doLoopStats(char* cmd) {
  for (int i = 0; i < 2; i++) {
    const LoopStats &s = i == 0 ? loop_on : loop_off;
    const char *state = i == 0 ? "on" : "off";
    if (s.count) command.com_port->printf("Wi-Fi %s: avg %lu us, max %lu us over %lu passes\n", state,
                                          (unsigned long)(s.total_us / s.count), (unsigned long)s.max_us, (unsigned long)s.count);
    else command.com_port->printf("Wi-Fi %s: no data yet\n", state);
  }
}

// R: RTC status -- its time now and the last once-a-minute check
void doRtcStatus(char* cmd) {
  if (!rtc_ok) {
    command.com_port->println("RTC not found at boot");
    return;
  }
  if (!rtcAnswers()) {
    command.com_port->printf("RTC not answering (%u failed checks in a row)\n", rtc_failures);
    return;
  }
  command.com_port->printf("RTC %lu UTC, %.2f C; last check %lu s ago, system clock was %+ld ms off\n",
                           (unsigned long)rtc.now().unixtime(), rtc.getTemperature(),
                           (millis() - last_rtc_check) / 1000, last_rtc_drift_ms);
}

void setup() {

  // mister on by default (S0 turns it off); set first thing since GPIO 15 is a strapping pin
  pinMode(MISTER_PIN, OUTPUT);
  digitalWrite(MISTER_PIN, HIGH);

  // use monitoring with serial
  Serial.begin(115200);
  Serial.println(F("FOC TIME build " __DATE__ " " __TIME__));  // also at GET /version, to check an update landed

  // hand calibration and mode, saved by N and H (doNoon, doHandMode)
  if (!prefs.begin("clock", false)) Serial.println("Opening the saved clock settings failed");
  top_angle = savedFloat(prefs, "top", 0);
  // 24 h unless H12 / H60 was saved ("sec": the earlier 60 s on/off switch)
  hand_turn = prefs.isKey("turn") ? prefs.getUChar("turn", 24) : prefs.getBool("sec", false) ? 60 : 24;
  last_night_sync = prefs.getULong("night", 0);
  // enable more verbose output for debugging
  // comment out if not needed
  SimpleFOCDebug::enable(&Serial);

  // local time zone, also before (or without) Wi-Fi
  setenv("TZ", time_zone, 1);
  tzset();

  // Wi-Fi + NTP -- from SimpleTime.ino, without its blocking wait: SNTP syncs in the background.
  // Wi-Fi then stays on for the boot window only (wifiWindow)
  sntp_set_time_sync_notification_cb(onNtpSync);  // NTP time -> RTC, see loop()
  webUsed();
  wifiOn();

  // web server routes, served while Wi-Fi is on (startOTA); no 1 ms sleep on every idle check (WebServer default)
  server.on("/", handleRoot);
  server.on("/version", handleVersion);
  server.on("/cmd", handleCmd);
  server.on("/update", handleUpdate);
  server.enableDelay(false);

  // RTC on its own I2C bus -- from rtc_test.cpp: starts the clock until NTP syncs
  Wire1.begin(RTC_SDA, RTC_SCL);
  Wire1.setClock(400000);
  Wire1.setTimeOut(5);  // a failed RTC transaction costs 5 ms instead of 50
  rtc_ok = checkRTC();

  // configure i2C -- from magnetic_sensor_i2c_example.ino / FOC 2815
  Wire.begin(21, 22);
  Wire.setClock(400000);
  // initialise magnetic sensor hardware
  sensor.init();
  // link the motor to the sensor
  motor.linkSensor(&sensor);

  // driver config
  driver.voltage_power_supply = SUPPLY_VOLTAGE;
  driver.init();
  // link the motor and the driver
  motor.linkDriver(&driver);

  motor.voltage_sensor_align = ALIGN_VOLTAGE;

  // set motion control loop to be used
  motor.controller = MotionControlType::angle;

  // contoller configuration -- values at the top of the file, unless a tuning was saved with TS
  if (!tuning.begin("tuning", false)) Serial.println("Opening the saved tuning failed");
  motor.PID_velocity.P = savedFloat(tuning, "vel_p", VEL_P);
  motor.PID_velocity.I = savedFloat(tuning, "vel_i", VEL_I);
  motor.voltage_limit = savedFloat(tuning, "volt_lim", VOLTAGE_LIMIT);
  motor.PID_velocity.output_ramp = savedFloat(tuning, "vel_ramp", VEL_RAMP);
  motor.LPF_velocity.Tf = savedFloat(tuning, "vel_tf", VEL_LPF_TF);
  motor.P_angle.P = savedFloat(tuning, "angle_p", ANGLE_P);
  motor.velocity_limit = savedFloat(tuning, "vel_lim", VELOCITY_LIMIT);
  motor.motion_downsample = tuning.getUInt("downsample", MOTION_DOWNSAMPLE);

  // comment out if not needed
  motor.useMonitoring(Serial);
  motor.monitor_downsample = 0;  // serial monitor output off by default, it costs loop time: MMD100 turns it on

  // initialize motor
  motor.init();
  // align sensor and start FOC
  motor.initFOC();
  // hold where the hand is until the time syncs
  target_angle = motor.shaft_angle;

  // add the motor to the commander interface
  command.add('M', doMotor, "motor");
  command.add('S', doMister, "mister");
  command.add('N', doNoon, "set noon");
  command.add('H', doHandMode, "hand 24/12/60");
  command.add('P', doPilot, "pilot jog/clock");
  command.add('T', doTuning, "tuning save/defaults");
  command.add('W', doWifi, "wifi");
  command.add('L', doLoopStats, "loop timing");
  command.add('R', doRtcStatus, "rtc");

  Serial.println(F("Motor ready."));
  _delay(1000);
}

void loop() {
  // loop period per Wi-Fi state (L)
  uint32_t now_us = micros();
  if (!skip_loop_sample) {
    uint32_t dt = now_us - last_loop_us;
    LoopStats &s = wifi_on ? loop_on : loop_off;
    s.count++;
    s.total_us += dt;
    if (dt > s.max_us) s.max_us = dt;
  }
  skip_loop_sample = false;
  last_loop_us = now_us;

  // main FOC algorithm function
  // the faster you run this function the better
  // Arduino UNO loop  ~1kHz
  // Bluepill loop ~10kHz
  motor.loopFOC();

  // the pilot hands back to the clock 10 min after the last jog, so the hand can't stay paused once Wi-Fi is off
  if (piloting && millis() - pilot_last >= PILOT_TIMEOUT_MS) {
    piloting = false;
    time_synced = false;
    Serial.println("Pilot: back to the clock");
  }

  // update the target: every 20 ms in 60 s mode so it ramps smoothly, once a second is plenty for 12 / 24 h
  if (millis() - last_clock_update >= (hand_turn == 60 ? 20UL : 1000UL)) {
    last_clock_update = millis();
    updateClockTarget();
  }

  // NTP synced: note it right away (that also ends the midnight sync) and drop any RTC check in progress,
  // which the new time made stale
  uint32_t events = ntp_events;
  if (events != ntp_seen) {
    ntp_seen = events;
    last_ntp_sync = time(nullptr);
    if (night_sync) {
      last_night_sync = last_ntp_sync;
      if (!prefs.putULong("night", (uint32_t)last_night_sync)) Serial.println("Saving the midnight sync failed");
    }
    night_sync = false;
    rtc_check_from = 0;
    rtc_write_pending = rtc_ok;
    rtc_write_since = millis();
  }

  // write the NTP time into the RTC right after a second boundary (the RTC counts whole seconds, and writing its
  // seconds register restarts its 1 s countdown) -- one short write on Wire1. If the loop keeps missing the 2 ms
  // window, take 20 ms after 5 s
  if (rtc_write_pending) {
    struct timeval tv;
    gettimeofday(&tv, nullptr);
    if (tv.tv_usec < 2000 || (millis() - rtc_write_since > 5000 && tv.tv_usec < 20000)) {
      rtc_write_pending = false;
      last_rtc_check = millis();  // the next RTC check a minute from now
      if (rtcAnswers()) {
        rtc.adjust(DateTime((uint32_t)tv.tv_sec));
        Serial.println("RTC set from NTP");
      } else {
        Serial.println("RTC not answering: not set from NTP");
      }
    }
  }

  // RTC check once a minute (5 ms polls for up to a second)
  rtcCheck();

  // once a second: Wi-Fi on or off
  if (millis() - last_wifi_check >= 1000) {
    last_wifi_check = millis();
    wifiWindow();
  }

  // while Wi-Fi is on: start OTA once connected, then check for an upload every 20 ms rather than on every FOC loop
  if (wifi_on && millis() - last_ota_check >= 20) {
    last_ota_check = millis();
    if (!ota_started && WiFi.status() == WL_CONNECTED) {
      startOTA();
      ota_started = true;
    }
    if (ota_started) {
      ArduinoOTA.handle();
      server.handleClient();
      if (update_url.length()) runPullUpdate();
    }
  }

  // motor back on (ME1, Set noon, a failed update): re-sync from where the hand is now, before move() drives it, so
  // a hand turned while the motor was off isn't pulled back. Without a valid time it holds there
  if (motor.enabled && !motor_on_last_loop) {
    sensor.update();  // fresh angle: a blocking update (OTA, pull update) kept loopFOC/move from reading it
    motor.shaft_angle = motor.shaftAngle();
    time_synced = false;
    target_angle = motor.shaft_angle;
    updateClockTarget();
  }
  motor_on_last_loop = motor.enabled;

  // Motion control function
  // velocity, position or voltage (defined in motor.controller)
  // this function can be run at much lower frequency than loopFOC() function
  // You can also use motor.move() and set the motor.target in the code
  motor.move(target_angle);

  // function intended to be used with serial plotter to monitor motor variables
  // significantly slowing the execution down!!!!
  motor.monitor();

  // user communication
  command.run();
}