techmannih/nema34-mounted-stepper-controller
A 48 V USB‑C PD 3.1 EPR NEMA‑34 stepper controller PCB combining STM32/TMC5160 control, external MOSFET motor bridges, protected power and regenerative braking, USB data, telemetry, diagnostics, status LEDs, and buzzer alarms.
- Version
- 0.3.92
- License
- unset
- Stars
- 1
firmware/target/main.cpp
#include <Arduino.h>
#include <Wire.h>
#include <SPI.h>
#include <IWatchdog.h>
#include "usb_suspend.h"
#include <stdio.h>
#include <string.h>
extern "C" {
#include "board_pins.h"
#include "control.h"
#include "brake_preflight.h"
#include "pd_contract.h"
#include "telemetry.h"
#include "power_domain.h"
#include "tmc5160.h"
#include "motor_service.h"
#include "tmc5160_config.h"
#include "local_motion.h"
}
// Commissioning image: motor outputs stay locked until the new ECO has passed
// route checks, programmed PD-image verification and powered bring-up.
// U7 mode-pin contention is corrected in source; firmware does not assume the PCB was rebuilt.
static constexpr bool motionWiringVerified = false;
static constexpr bool pdImageVerified = false;
static constexpr bool brakeHardwareVerified = false;
static constexpr bool motionQualified = motionWiringVerified && pdImageVerified && brakeHardwareVerified;
static pd1180_brake_preflight_t brakePreflight;
static TwoWire pdBus(PB7, PB6);
static SPIClass motorSpi(PA7, PA6, PA5);
static bool spiStarted;
static bool motorFrame(void *, const uint8_t tx[5], uint8_t rx[5]);
static pd1180_motor_service_t motor = {};
static auto &tmc = motor.driver;
static auto &tmcStatus = motor.status;
static constexpr uint16_t commissioningCurrentPermille = PD1180_BENCH_CURRENT_MAX_PERMILLE; // Start below 1 A RMS; tune on the bench.
static uint32_t lastDriverPoll;
static pd1180_control_t control;
static pd1180_local_motion_t localMotion;
static pd1180_inputs_t inputs;
static pd1180_board_power_t boardPower;
static bool peripheralsStarted, wasSuspended;
static bool indicatorsReady, indicatorResetReleased;
static uint32_t indicatorAttemptAt, lastIndicatorPoll;
static uint32_t boardMv;
static uint8_t pdMode[4], pdIdentity[8], pdo[6], rdo[12], powerStatus[2], pdStatus[4];
static uint32_t lastPoll, lastTick, lastGoodSample, vbusMv, vmotorMv;
static uint32_t lastTemperaturePoll, lastTemperatureSample, temperatureConfiguredAt;
static uint32_t lastAdcPoll, lastPhaseSample, adcSampleAt[3];
static uint16_t adcMv[3];
static int32_t phaseMa[2];
static bool temperatureConfigured, adcPending, adcValid[3], phaseValid;
static uint8_t adcChannel;
static constexpr uint16_t temperatureConfig = 0x08C0; // Two faults, active-low comparator, 8 Hz.
static char response[384];
static size_t responseLength, responseSent;
static bool pdReadOk, usbStarted;
static char command[64];
static size_t commandLength;
static bool commandOverflow;
static pin_size_t pin(board_pin_t p) {
return pinNametoDigitalPin(static_cast<PinName>(((p.port-'A')<<4) | p.bit));
}
static void safeOutputs() {
digitalWrite(pin(PD1180_PIN_MCU_RUN), LOW);
digitalWrite(pin(PD1180_PIN_POWER_PERMIT), LOW);
digitalWrite(pin(PD1180_PIN_SD_MODE), LOW);
}
static void output(board_pin_t p, bool high) {
digitalWrite(pin(p), high ? HIGH : LOW); // Set latch before making pin an output.
pinMode(pin(p), OUTPUT);
}
static const board_pin_t boardInputs[] = {PD1180_PIN_EFUSE_FAULT_N,PD1180_PIN_MOTOR_PG,PD1180_PIN_VMOTOR_OK,
PD1180_PIN_TMC_DIAG0,PD1180_PIN_TMC_DIAG1};
static void releaseChipSelect(board_pin_t p) {
digitalWrite(pin(p), HIGH);
pinMode(pin(p), OUTPUT_OPEN_DRAIN); // Only external board-domain pullups drive high.
}
static void invalidateTelemetry() {
inputs = {};
brakePreflight = {};
adcChannel = 0;
pd1180_local_motion_cancel(&localMotion);
temperatureConfigured = false;
adcPending = false;
adcValid[0] = adcValid[1] = adcValid[2] = phaseValid = pdReadOk = false;
adcMv[0] = adcMv[1] = adcMv[2] = 0;
phaseMa[0] = phaseMa[1] = 0;
vmotorMv = 0;
pd1180_motor_service_reset(&motor);
pd1180_control_disarm(&control);
}
static void stopPeripherals() {
safeOutputs();
// U35 RESET is hardware-gated: no I2C transaction is needed to silence BZ1.
digitalWrite(pin(PD1180_PIN_STATUS_GPIO), LOW);
indicatorsReady = indicatorResetReleased = false;
if (peripheralsStarted) pdBus.end();
if (spiStarted) motorSpi.end();
spiStarted=false;
releaseChipSelect(PD1180_PIN_TMC_CS_N);
for (auto p : {PD1180_PIN_SPI_SCK, PD1180_PIN_SPI_MOSI, PD1180_PIN_SPI_MISO})
pinMode(pin(p), INPUT_ANALOG);
// No internal pullups or push-pull highs may energize the dead board rail.
pinMode(pin(PD1180_PIN_PD_SCL), INPUT_ANALOG);
pinMode(pin(PD1180_PIN_PD_SDA), INPUT_ANALOG);
for (auto p : boardInputs) pinMode(pin(p), INPUT_ANALOG);
peripheralsStarted = false;
invalidateTelemetry();
}
static void startPeripherals() {
for (auto p : boardInputs) pinMode(pin(p), INPUT);
pdBus.begin(); pdBus.setClock(100000);
peripheralsStarted = true;
temperatureConfiguredAt = millis();
indicatorAttemptAt = millis();
digitalWrite(pin(PD1180_PIN_STATUS_GPIO), HIGH);
indicatorResetReleased = true;
}
// U7 needs VMOTOR as well as VCC_IO. Do not clock or drive its inputs when
// the motor domain is absent, even if the encoder/board logic is powered.
static bool motorFrame(void *, const uint8_t tx[5], uint8_t rx[5]) {
if (!spiStarted || !peripheralsStarted || !inputs.motor_power_good ||
!inputs.vmotor_in_range || (usbStarted && usbIsSuspended())) return false;
motorSpi.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE3));
digitalWrite(pin(PD1180_PIN_TMC_CS_N), LOW);
for (unsigned i=0;i<5;++i) rx[i]=motorSpi.transfer(tx[i]);
digitalWrite(pin(PD1180_PIN_TMC_CS_N), HIGH);
motorSpi.endTransaction();
delayMicroseconds(2); // Explicit CS-high gap; ADI requires >=2 system clocks.
return true;
}
static void pollDriver(uint32_t now) {
if (!inputs.motor_power_good || !inputs.vmotor_in_range) {
if (spiStarted) motorSpi.end();
spiStarted=false; pd1180_motor_service_reset(&motor);
inputs.driver_ready=false;
for (auto p : {PD1180_PIN_SPI_SCK,PD1180_PIN_SPI_MOSI,PD1180_PIN_SPI_MISO})
pinMode(pin(p),INPUT_ANALOG);
return;
}
if (!spiStarted) { motorSpi.begin(); spiStarted=true; }
if (now-lastDriverPoll<20u) return;
lastDriverPoll=now;
// Read-only in the unqualified commissioning image. Faults are not cleared
// automatically; every reset/power loss invalidates the configured state.
if (motionQualified) {
pd1180_motor_service(&motor,&control,&inputs,commissioningCurrentPermille);
} else {
pd1180_tmc_status(&tmc,&tmcStatus);
inputs.driver_ready=false;
}
}
static void reply(const char *text) {
if (!usbStarted || responseSent < responseLength) return;
responseLength = strnlen(text, sizeof(response)-1);
memcpy(response,text,responseLength);
responseSent = 0;
}
static bool pdRead(uint8_t reg, uint8_t *data, size_t len) {
if (usbStarted && usbIsSuspended()) return false;
memset(data,0,len);
pdBus.beginTransmission(0x20);
pdBus.write(reg);
if (pdBus.endTransmission(false) != 0 || (usbStarted && usbIsSuspended())) return false;
if (pdBus.requestFrom(0x20, static_cast<int>(len+1), static_cast<int>(true)) != len+1) {
while(pdBus.available()) pdBus.read();
return false;
}
const int count = pdBus.read();
if (count < static_cast<int>(len) || count > 64) {
while(pdBus.available()) pdBus.read();
return false;
}
for (size_t n=0;n<len;++n) data[n] = static_cast<uint8_t>(pdBus.read());
return true;
}
// These devices use ordinary big-endian 16-bit registers, unlike the PD count-byte protocol.
static bool readRegister(uint8_t address, uint8_t reg, uint16_t &value) {
if (usbStarted && usbIsSuspended()) return false;
pdBus.beginTransmission(address); pdBus.write(reg);
if (pdBus.endTransmission(false) != 0 || (usbStarted && usbIsSuspended())) return false;
if (pdBus.requestFrom(address, 2, static_cast<int>(true)) != 2) {
while (pdBus.available()) pdBus.read();
return false;
}
value = static_cast<uint16_t>(pdBus.read() << 8) | pdBus.read();
return true;
}
static bool writeRegister(uint8_t address, uint8_t reg, uint16_t value) {
if (usbStarted && usbIsSuspended()) return false;
pdBus.beginTransmission(address); pdBus.write(reg);
pdBus.write(static_cast<uint8_t>(value >> 8)); pdBus.write(static_cast<uint8_t>(value));
return pdBus.endTransmission() == 0;
}
// PCA9538 (reset buffered into PD_3V3 by U37): A1/A0 grounded (0x70); P0/P1/P2 sink RGB, P3 drives the alarm MOSFET.
// R132 holds RESET low during MCU reset. R127 keeps the buzzer gate low while
// expander ports are inputs. The output latch is initialized before direction.
static bool indicatorWrite(uint8_t reg, uint8_t value) {
if (!peripheralsStarted || (usbStarted && usbIsSuspended())) return false;
pdBus.beginTransmission(0x70); pdBus.write(reg); pdBus.write(value);
return pdBus.endTransmission() == 0;
}
static bool indicatorRead(uint8_t reg, uint8_t &value) {
if (!peripheralsStarted || (usbStarted && usbIsSuspended())) return false;
pdBus.beginTransmission(0x70); pdBus.write(reg);
if (pdBus.endTransmission(false) != 0 || (usbStarted && usbIsSuspended())) return false;
if (pdBus.requestFrom(0x70, 1, static_cast<int>(true)) != 1) {
while (pdBus.available()) pdBus.read();
return false;
}
value = static_cast<uint8_t>(pdBus.read());
return true;
}
static void resetIndicators(uint32_t now) {
digitalWrite(pin(PD1180_PIN_STATUS_GPIO), LOW);
indicatorsReady = indicatorResetReleased = false;
indicatorAttemptAt = now;
}
static void pollIndicators(uint32_t now) {
if (!peripheralsStarted) return;
if (usbStarted && usbIsSuspended()) { resetIndicators(now); return; }
if (!indicatorResetReleased) {
if (now - indicatorAttemptAt < 1000u) return;
digitalWrite(pin(PD1180_PIN_STATUS_GPIO), HIGH);
indicatorResetReleased = true;
indicatorAttemptAt = now;
return;
}
if (now - indicatorAttemptAt < 1u || now - lastIndicatorPoll < 50u) return;
lastIndicatorPoll = now;
if (!indicatorsReady) {
if (!indicatorWrite(1, 0x07) || !indicatorWrite(2, 0) || !indicatorWrite(3, 0xF0)) {
resetIndicators(now); return;
}
}
const bool fault = control.fault_latched || (tmcStatus.valid && tmcStatus.fault) ||
(inputs.temperature_valid && inputs.temperature_deci_c >= 700);
uint8_t value;
if (fault) value = 0x06 | ((now % 2000u < 200u) ? 0x08 : 0); // Red; bounded chirp.
else if (!motionQualified) value = 0x03; // Blue: commissioning lock.
else if (!inputs.sample_fresh || !inputs.temperature_valid || !inputs.epr_contract)
value = 0x04; // Amber: waiting for qualified power/telemetry.
else value = 0x05; // Green: qualified, with RUN reported separately over USB.
uint8_t config, actual;
// Detect a reset/brownout or failed write instead of leaving a stale alert latched.
if (!indicatorRead(3, config) || config != 0xF0 || !indicatorWrite(1, value) ||
!indicatorRead(1, actual) || actual != value) {
resetIndicators(now); return;
}
indicatorsReady = true;
}
static void pollTemperature(uint32_t now) {
if (now-lastTemperaturePoll < 125u) return;
lastTemperaturePoll=now;
uint16_t cfg, low, high, raw;
const bool configured = readRegister(0x48,1,cfg) && (cfg & 0x1FD0u)==temperatureConfig &&
readRegister(0x48,2,low) && low==0x3700 && readRegister(0x48,3,high) && high==0x4600;
if (!configured) {
inputs.temperature_valid=false;
temperatureConfigured=false;
// Reinitialization after sensor reset cannot immediately make a stale sample valid.
if (writeRegister(0x48,2,0x3700) && writeRegister(0x48,3,0x4600) &&
writeRegister(0x48,1,temperatureConfig)) temperatureConfiguredAt=now;
return;
}
temperatureConfigured=true;
if (now-temperatureConfiguredAt < 150u || !readRegister(0x48,0,raw)) {
inputs.temperature_valid=false;
return;
}
inputs.temperature_deci_c=pd1180_tmp102_deci_c(raw);
inputs.temperature_valid=inputs.temperature_deci_c>=-400 && inputs.temperature_deci_c<=1250;
if (inputs.temperature_valid) lastTemperatureSample=now;
}
static void pollBusAdc(uint32_t now) {
if (now-lastAdcPoll < 10u) return;
lastAdcPoll=now;
const uint16_t config=0x8383u | (static_cast<uint16_t>(4u+adcChannel)<<12);
if (!adcPending) {
adcPending=writeRegister(0x49,1,config);
if (!adcPending) {adcValid[0]=false; adcValid[1]=false; adcValid[2]=false;}
return;
}
uint16_t status, raw;
if (!readRegister(0x49,1,status) || (status & 0x7FFFu)!=(config & 0x7FFFu)) {
adcPending=false; adcValid[0]=false; adcValid[1]=false; adcValid[2]=false; return;
}
if (!(status & 0x8000u)) return;
adcValid[adcChannel]=readRegister(0x49,0,raw) &&
pd1180_ads1115_mv(static_cast<int16_t>(raw),&adcMv[adcChannel]);
if (adcValid[adcChannel]) adcSampleAt[adcChannel]=now;
adcChannel=(adcChannel+1u)%3u; adcPending=false;
}
static void samplePhaseCurrent(uint32_t now) {
if (now-lastPhaseSample < 1u) return;
lastPhaseSample=now;
const bool a=pd1180_phase_current_ma(analogRead(pin(PD1180_PIN_PHASE_A_ADC)),2048,3300,&phaseMa[0]);
const bool b=pd1180_phase_current_ma(analogRead(pin(PD1180_PIN_PHASE_B_ADC)),2048,3300,&phaseMa[1]);
phaseValid=a && b;
// Instantaneous, nominal-VDDA diagnostics; not synchronous current control or an RMS measurement.
}
static uint32_t dividerMillivolts(board_pin_t input, uint32_t topK, uint32_t bottomK) {
// VDDA is nominal 3.3 V; diagnostics only until measured/calibrated on hardware.
return static_cast<uint32_t>((static_cast<uint64_t>(analogRead(pin(input))) * 3300u *
(topK+bottomK)) / (4095u*bottomK));
}
static bool pdConfigurationMatches(const uint8_t *identity) {
static constexpr uint8_t expected[8] = {'P','D','1','1','8','0','4','1'};
return memcmp(identity, expected, sizeof(expected)) == 0;
}
static void pollPower() {
vbusMv = dividerMillivolts(PD1180_PIN_USB_VBUS_SENSE,1000,47);
vmotorMv = static_cast<uint32_t>(adcMv[0]) * 19u; // 360k / 20k divider, ADS1115 AIN0.
// TI I2C reads include the leading byte count; all fields must succeed.
pdReadOk = pdRead(0x03,pdMode,sizeof(pdMode)) && pdRead(0x06,pdIdentity,sizeof(pdIdentity)) && pdRead(0x34,pdo,sizeof(pdo)) &&
pdRead(0x35,rdo,sizeof(rdo)) && pdRead(0x3F,powerStatus,sizeof(powerStatus)) &&
pdRead(0x40,pdStatus,sizeof(pdStatus));
if(pdReadOk) lastGoodSample=millis();
const pd1180_contract_t contract = pd1180_decode_contract(pdMode,pdo,rdo,powerStatus,pdStatus);
inputs.pd_app_mode = pdReadOk && memcmp(pdMode,"APP ",4)==0;
inputs.epr_contract = pdReadOk && contract.valid;
inputs.contract_mv = contract.mv;
inputs.contract_ma = contract.ma;
inputs.configuration_verified = pdImageVerified && pdReadOk && pdConfigurationMatches(pdIdentity);
inputs.motion_wiring_verified = motionWiringVerified && brakeHardwareVerified;
inputs.pd_fault = !pdReadOk;
inputs.efuse_fault = digitalRead(pin(PD1180_PIN_EFUSE_FAULT_N)) == LOW;
inputs.motor_power_good = digitalRead(pin(PD1180_PIN_MOTOR_PG)) == HIGH;
inputs.vmotor_in_range = digitalRead(pin(PD1180_PIN_VMOTOR_OK)) == HIGH && adcValid[0] &&
millis()-adcSampleAt[0]<=100u && vmotorMv >= 44000 && vmotorMv <= 51000;
inputs.brake_preflight_ok = pd1180_brake_preflight_update(&brakePreflight,
control.power_permit, control.mcu_run, adcValid[0] && adcValid[2] &&
millis()-adcSampleAt[0]<=100u && millis()-adcSampleAt[2]<=100u,
millis(), adcSampleAt[0], adcSampleAt[2], vmotorMv, static_cast<uint32_t>(adcMv[2])*991u/51u);
inputs.watchdog_healthy = true;
inputs.driver_ready = motionQualified && tmc.configured &&
tmcStatus.valid && !tmcStatus.fault && millis()-lastDriverPoll<=40u;
inputs.stop_active = false; // External limit interface removed; local STOP uses the controlled ramp.
}
static void executeCommand() {
command[commandLength]=0;
if(!strcmp(command,"DISARM")) {
safeOutputs(); pd1180_local_motion_cancel(&localMotion); pd1180_control_disarm(&control); pd1180_motor_service_reset(&motor); reply("OK DISARM\r\n");
} else if(!strcmp(command,"ARM")) {
reply("BLOCKED: use local START/STOP; hardware qualification required\r\n");
} else if(!strcmp(command,"DRIVER")) {
char status[160];
snprintf(status,sizeof(status),"TMC_VALID=%u IOIN=%08lX GSTAT=%08lX DRV_STATUS=%08lX CONFIGURED=%u FAULT=%u\r\n",
tmcStatus.valid,static_cast<unsigned long>(tmcStatus.ioin),static_cast<unsigned long>(tmcStatus.gstat),
static_cast<unsigned long>(tmcStatus.drv_status),tmc.configured,tmcStatus.fault);
reply(status);
} else if(!strcmp(command,"STATUS")) {
char status[384];
snprintf(status,sizeof(status),"BOARD_MV=%lu BOARD_OK=%u PD_OK=%u EPR48=%u DATA_VBUS_MV=%lu VMOTOR_MV=%lu BRAKE_OK=%u BUS_ADC_OK=%u IIN_MON_MV=%u IIN_ADC_OK=%u TEMP_DC=%d TEMP_OK=%u PHASE_A_MA=%ld PHASE_B_MA=%ld PHASE_OK=%u ADC_CALIBRATED=0 PG=%u WINDOW=%u RUN=%u INDICATORS_OK=%u BRINGUP_REQUIRED=%u\r\n",
static_cast<unsigned long>(boardMv),peripheralsStarted,pdReadOk,inputs.epr_contract,static_cast<unsigned long>(vbusMv),static_cast<unsigned long>(vmotorMv),
inputs.brake_preflight_ok, adcValid[0] && millis()-adcSampleAt[0]<=100u,adcMv[1],adcValid[1] && millis()-adcSampleAt[1]<=100u,
inputs.temperature_deci_c,inputs.temperature_valid,static_cast<long>(phaseMa[0]),static_cast<long>(phaseMa[1]),phaseValid,
inputs.motor_power_good,inputs.vmotor_in_range,control.mcu_run && inputs.driver_ready,indicatorsReady,
!motionQualified);
reply(status);
} else if(!strcmp(command,"HELP")) {
reply("PD1180 commissioning: STATUS, DRIVER, DISARM, ARM (blocked), HELP. No motion enabled.\r\n");
} else reply("ERR unknown command\r\n");
}
void setup() {
tmc.frame=motorFrame;
output(PD1180_PIN_MCU_RUN,false); output(PD1180_PIN_POWER_PERMIT,false); output(PD1180_PIN_SD_MODE,false);
// Removed field-bus and unassigned general I/O pins remain in reset input state.
releaseChipSelect(PD1180_PIN_TMC_CS_N); releaseChipSelect(PD1180_PIN_ENC_CS_N); releaseChipSelect(PD1180_PIN_FLASH_CS_N);
output(PD1180_PIN_STATUS_GPIO,false);
pinMode(pin(PD1180_PIN_USER_BUTTON_N), INPUT); // R133 is on the MCU rail.
analogReadResolution(12);
pd1180_control_reset(&control);
IWatchdog.begin(250000); // 250 ms nominal; never serviced by interrupt handlers.
initializeSuspendTimer();
stopPeripherals();
lastTick=lastPoll=millis();
}
void loop() {
// Qualification is a firmware build decision, never a USB command override.
if (!motionQualified) safeOutputs();
const uint32_t now=millis();
vbusMv = dividerMillivolts(PD1180_PIN_USB_VBUS_SENSE,1000,47);
if (usbStarted && usbIsSuspended() && vbusMv >= 3000u) {
if (!wasSuspended) {
stopPeripherals(); boardPower = {};
commandLength=0; commandOverflow=false; responseLength=responseSent=0;
digitalWrite(pin(PD1180_PIN_STATUS_GPIO),LOW);
wasSuspended=true;
}
IWatchdog.reload();
sleepWhileUsbSuspended();
return;
}
if (wasSuspended) { wasSuspended=false; lastTick=lastPoll=now; }
boardMv = dividerMillivolts(PD1180_PIN_BOARD_POWER_SENSE,10,10);
const bool boardReady=pd1180_board_power_update(&boardPower,boardMv,now);
if (peripheralsStarted && !boardReady) stopPeripherals();
if (!peripheralsStarted && boardReady) startPeripherals();
if (peripheralsStarted) {
pollTemperature(now); pollBusAdc(now); samplePhaseCurrent(now);
if (!temperatureConfigured || now-lastTemperatureSample>500u) inputs.temperature_valid=false;
if(now-lastPoll >= 50u) { lastPoll=now; pollPower(); }
pollDriver(now);
}
if(!usbStarted && vbusMv >= 4000u) { SerialUSB.begin(); usbStarted=true; }
if(usbStarted && vbusMv < 3000u) {
SerialUSB.end(); usbStarted=false; commandLength=0; commandOverflow=false;
responseLength=responseSent=0;
safeOutputs(); pd1180_local_motion_cancel(&localMotion); pd1180_control_disarm(&control);
}
if(usbStarted) {
if (SerialUSB && responseSent<responseLength) {
const size_t count=min(responseLength-responseSent,static_cast<size_t>(SerialUSB.availableForWrite()));
if (count) responseSent+=SerialUSB.write(reinterpret_cast<const uint8_t *>(response)+responseSent,count);
}
// Bounded parser: oversized lines are discarded whole, never as command suffixes.
for(unsigned n=0; n<32 && SerialUSB.available(); ++n) {
const char c=static_cast<char>(SerialUSB.read());
if(c=='\r') continue;
if(c=='\n') {
if(commandOverflow) reply("ERR line too long\r\n");
else executeCommand();
commandLength=0; commandOverflow=false;
} else if(!commandOverflow) {
if(commandLength+1 < sizeof(command)) command[commandLength++]=c;
else {commandOverflow=true; commandLength=0;}
}
}
}
const uint32_t tick=millis();
if(tick-lastTick >= 10u) {
inputs.sample_fresh=pdReadOk && tick-lastGoodSample<=100u;
pd1180_control_tick(&control,&inputs,tick-lastTick); lastTick=tick;
}
if (motionQualified) {
digitalWrite(pin(PD1180_PIN_POWER_PERMIT),control.power_permit?HIGH:LOW);
digitalWrite(pin(PD1180_PIN_SD_MODE),LOW); // TMC internal SPI ramp, never STEP/DIR.
digitalWrite(pin(PD1180_PIN_MCU_RUN),control.mcu_run && inputs.driver_ready?HIGH:LOW);
} else safeOutputs();
pd1180_local_motion_update(&localMotion,&control,&inputs,&tmc,
digitalRead(pin(PD1180_PIN_USER_BUTTON_N))==LOW,tick,
motionQualified && peripheralsStarted);
// Apply cancellations and transport/stop-timeout failures before any other work.
if (!control.armed || control.fault_latched) safeOutputs();
pollIndicators(tick);
IWatchdog.reload();
}